diff --git a/.github/workflows/brew-release.yml b/.github/workflows/brew-release.yml new file mode 100644 index 00000000..42a9be9e --- /dev/null +++ b/.github/workflows/brew-release.yml @@ -0,0 +1,74 @@ +name: Update Homebrew Cask + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: 'Version for testing (e.g., 0.2.5; omit "v" prefix)' + required: false + type: string + default: '' + dmg_url: + description: 'Full DMG download URL for testing (e.g., https://github.com/the-ora/browser/releases/download/v0.2.5/Ora-Browser-0.2.5.dmg)' + required: false + type: string + default: '' + +jobs: + update-cask: + runs-on: ubuntu-latest + steps: + - name: Checkout homebrew-ora repo + uses: actions/checkout@v4 + with: + repository: the-ora/homebrew-ora + token: ${{ secrets.HOMEBREW_SECRET }} + ref: main + + - name: Download Ora.dmg and set version + run: | + EVENT_NAME="${{ github.event_name }}" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ -n "${{ github.event.inputs.dmg_url }}" ]; then + DOWNLOAD_URL="${{ github.event.inputs.dmg_url }}" + else + echo "DMG URL is required for manual dispatch" + exit 1 + fi + if [ -n "${{ github.event.inputs.version }}" ]; then + STRIPPED_VERSION="${{ github.event.inputs.version }}" + else + echo "Version is required for manual dispatch" + exit 1 + fi + else + VERSION="${{ github.event.release.tag_name }}" + STRIPPED_VERSION=$(echo "$VERSION" | sed 's/^v//') + DOWNLOAD_URL="https://github.com/the-ora/browser/releases/download/${VERSION}/Ora-Browser-${STRIPPED_VERSION}.dmg" + fi + echo "STRIPPED_VERSION=$STRIPPED_VERSION" >> $GITHUB_ENV + curl -L --fail -o Ora.dmg "$DOWNLOAD_URL" + + - name: Compute SHA256 + run: | + SHA256=$(shasum -a 256 Ora.dmg | awk '{print $1}') + echo "SHA256=$SHA256" >> $GITHUB_ENV + + - name: Update ora.rb + run: | + sed -i 's/version ".*"/version "${{ env.STRIPPED_VERSION }}"/g' Casks/ora.rb + sed -i 's/sha256 ".*"/sha256 "${{ env.SHA256 }}"/g' Casks/ora.rb + + - name: Commit and push changes + run: | + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git add Casks/ora.rb + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Update Ora cask to ${{ env.STRIPPED_VERSION }} (SHA256: ${{ env.SHA256 }})" + git push + fi \ No newline at end of file diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index bf4ec875..bc2652e4 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,4 +1,4 @@ -name: Lint and Formating +name: Lint and Formatting on: push: @@ -17,6 +17,7 @@ jobs: steps: - name: Check architecture run: uname -m + - name: Checkout code uses: actions/checkout@v4 @@ -35,11 +36,17 @@ jobs: - name: Generate Xcode project run: xcodegen - - name: Install Swiftlint + - name: Install SwiftLint run: brew install swiftlint - - name: Run Swiftlint - run: swiftlint lint . --quiet + - name: Run SwiftLint + run: swiftlint lint + + - name: Install SwiftFormat + run: brew install swiftformat + + - name: Check code formatting + run: swiftformat --lint . - name: Install Xcbeautify run: brew install xcbeautify @@ -58,38 +65,4 @@ jobs: ${{ runner.os }}-spm- - name: Resolve dependencies - run: xcodebuild -resolvePackageDependencies - - # - name: Build project - # run: | - # chmod +x xcbuild-debug.sh - # ./xcbuild-debug.sh - - # - name: Run tests - # run: | - # xcodebuild test \ - # -scheme ora \ - # -destination "platform=macOS" \ - # -configuration Debug \ - # CODE_SIGN_IDENTITY="" \ - # CODE_SIGNING_REQUIRED=NO \ - # -enableCodeCoverage YES \ - # -resultBundlePath TestResults - - # - name: Upload test results - # uses: actions/upload-artifact@v4 - # if: always() - # with: - # name: test-results - # path: TestResults.xcresult - - # - name: Generate code coverage report - # run: | - # xcrun xccov view --report --json TestResults.xcresult > coverage.json - # xcrun xccov view --report TestResults.xcresult - - # - name: Upload coverage reports to Codecov - # uses: codecov/codecov-action@v3 - # with: - # files: ./coverage.json - # fail_ci_if_error: false \ No newline at end of file + run: xcodebuild -resolvePackageDependencies \ No newline at end of file diff --git a/.gitignore b/.gitignore index 58a03de7..04aa6706 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,4 @@ codesign* # Uncomment these if you want to commit release artifacts: # !build/*.dmg # !build/appcast.xml -# !build/dsa_pub.pem \ No newline at end of file +# !build/dsa_pub.pem diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d159169d --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index fdfc4886..00000000 --- a/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 - Present, The Ora Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index f47e256b..e640e5c8 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@

- macOS + macOS Xcode Swift Version Homebrew - License: MIT + License: MIT

> **⚠️ Disclaimer** @@ -117,5 +117,5 @@ Questions or support? Join the community on [Discord](https://discord.gg/9aZWH52 ## License -Ora is open source and licensed under the [MIT License](LICENSE.md). -Feel free to use, modify, and distribute it under the terms of the MIT License. +Ora is open source and licensed under the [GPL-2.0 license](LICENSE). +Feel free to use, modify, and distribute it under the terms of the GPL-2.0 license. diff --git a/appcast.xml b/appcast.xml index c03c07ef..326a6525 100644 --- a/appcast.xml +++ b/appcast.xml @@ -5,45 +5,43 @@ Most recent changes with links to updates. en - Version 0.2.3 + Version 0.2.5 Ora Browser v0.2.3 +

Ora Browser v0.2.5

Changes since last release:

Features

    -
  • feat: add JavaScript alert, confirm, and prompt handling (#108) — Kenenisa Alemayehu
  • +
  • feat: support for duplicate tab (#129) — Brooksolomon
  • +
  • feat: Add Auto Picture-in-Picture Feature and Refactor Favicon Handling (#146) — Kenenisa Alemayehu
  • +
  • feat: enhance brew release workflow with optional inputs for version and DMG URL — Kenenisa Alemayehu
  • +
  • feat: brew release cask automation — Kenenisa Alemayehu

Fixes

    -
  • fix: prevent appearance update crash when NSApp is nil (#115) — Kenenisa Alemayehu
  • -
  • fix(tabs): add keyboard shortcuts for tab selection and notification handling (#106) — Furkan Koseoglu
  • -
  • fix: closing tab animation (#53) — Roman Potapov
  • -
  • fix(sidebar): fixes sidebar behavior with downloads widgets when side… (#100) — Furkan Koseoglu
  • -
  • fix: mini player spacing on sidebar — yonaries
  • -
  • fix: min and max launcher width — yonaries
  • +
  • fix: update GitHub token to HOMEBREW_SECRET in brew release workflow — Kenenisa Alemayehu
  • +
  • fix: update paths for ora.rb in brew release workflow — Kenenisa Alemayehu
  • +
  • fix: sed for ora.rb — Kenenisa Alemayehu
  • +
  • fix: update GitHub token in brew release workflow — Kenenisa Alemayehu

Chores

    -
  • chore: swiftlint — yonaries
  • -
  • chore: skip version bump commits in release script — Kenenisa Alemayehu
  • -
  • chore: update .gitignore to exclude backup files and remove obsolete appcast.xml.backup — Kenenisa Alemayehu
  • +
  • chore: update discord link — Yonathan Dejene

Other

    -
  • Add custom keyboard shortcut support + update UI (#89) — Joe McLaughlin
  • -
  • Add horizontal padding to search bar in LauncherMain SwiftUI view (#110) — FormalSnake
  • -
  • Update Wiki link in README.md (#104) — Aether
  • +
  • added swiftformat — yonaries
  • +
  • Add Left/Right Sidebar Positioning, Floating URL Bar, and Toolbar Enhancements (#133) — Yonathan Dejene
]]>
- Sat, 20 Sep 2025 08:43:15 +0000 - Mon, 13 Oct 2025 20:41:08 +0000 + + sparkle:edSignature="a4beTLCi7kMhmqbuI57XRbOooPWonvq9d/5aou/kPuY9mqJW2uNLDFLKhcFUmdJdye3ZarkSkcZkrPEhCguCAg=="/>
diff --git a/ora/Common/Constants/AppEvents.swift b/ora/Common/Constants/AppEvents.swift index 30d5c4be..939a40b9 100644 --- a/ora/Common/Constants/AppEvents.swift +++ b/ora/Common/Constants/AppEvents.swift @@ -2,6 +2,7 @@ import Foundation extension Notification.Name { static let toggleSidebar = Notification.Name("ToggleSidebar") + static let toggleSidebarPosition = Notification.Name("ToggleSidebarPosition") static let copyAddressURL = Notification.Name("CopyAddressURL") static let showLauncher = Notification.Name("ShowLauncher") @@ -21,4 +22,7 @@ extension Notification.Name { // Per-window settings/events static let setAppearance = Notification.Name("SetAppearance") // userInfo: ["appearance": String] static let checkForUpdates = Notification.Name("CheckForUpdates") + + // AppDelegate → UI routing + static let openURL = Notification.Name("OpenURL") // userInfo: ["url": URL] } diff --git a/ora/Common/Constants/ContainerConstants.swift b/ora/Common/Constants/ContainerConstants.swift new file mode 100644 index 00000000..f3e9ff11 --- /dev/null +++ b/ora/Common/Constants/ContainerConstants.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Constants related to container functionality +enum ContainerConstants { + /// Default emoji used when no emoji is selected for a container + static let defaultEmoji = "•" + + /// Default time in seconds after which a tab is no longer considered alive + static let defaultTabAliveTimeout: TimeInterval = 60 * 60 // 1 hour + + /// Default time in seconds after which normal tabs are completely removed + static let defaultTabRemovalTimeout: TimeInterval = 24 * 60 * 60 // 1 day + + /// UI constants for container forms and displays + enum UI { + static let normalButtonWidth: CGFloat = 28 + static let compactButtonWidth: CGFloat = 12 + static let popoverWidth: CGFloat = 300 + static let emojiButtonSize: CGFloat = 32 + static let cornerRadius: CGFloat = 10 + } + + /// Animation constants for container interactions + enum Animation { + static let hoverDuration: Double = 0.15 + static let emojiPickerDuration: Double = 0.1 + } +} diff --git a/ora/Common/Constants/KeyboardShortcuts.swift b/ora/Common/Constants/KeyboardShortcuts.swift index 2f1a12e2..2394668f 100644 --- a/ora/Common/Constants/KeyboardShortcuts.swift +++ b/ora/Common/Constants/KeyboardShortcuts.swift @@ -113,6 +113,21 @@ enum KeyboardShortcuts { category: "Tabs", defaultChord: KeyChord(keyEquivalent: .init("9"), modifiers: [.command]) ) + + static func keyboardShortcut(for index: Int) -> KeyboardShortcut { + switch index { + case 1: return tab1.keyboardShortcut + case 2: return tab2.keyboardShortcut + case 3: return tab3.keyboardShortcut + case 4: return tab4.keyboardShortcut + case 5: return tab5.keyboardShortcut + case 6: return tab6.keyboardShortcut + case 7: return tab7.keyboardShortcut + case 8: return tab8.keyboardShortcut + case 9: return tab9.keyboardShortcut + default: return tab1.keyboardShortcut + } + } } // MARK: - Navigation diff --git a/ora/Common/Extensions/EnvironmentValues+Window.swift b/ora/Common/Extensions/EnvironmentValues+Window.swift new file mode 100644 index 00000000..1655d940 --- /dev/null +++ b/ora/Common/Extensions/EnvironmentValues+Window.swift @@ -0,0 +1,13 @@ +import AppKit +import SwiftUI + +private struct WindowEnvironmentKey: EnvironmentKey { + static let defaultValue: NSWindow? = nil +} + +extension EnvironmentValues { + var window: NSWindow? { + get { self[WindowEnvironmentKey.self] } + set { self[WindowEnvironmentKey.self] = newValue } + } +} diff --git a/ora/Common/Extensions/ModelConfiguration+Shared.swift b/ora/Common/Extensions/ModelConfiguration+Shared.swift new file mode 100644 index 00000000..fd0ee5c4 --- /dev/null +++ b/ora/Common/Extensions/ModelConfiguration+Shared.swift @@ -0,0 +1,25 @@ +import Foundation +import SwiftData + +extension ModelConfiguration { + /// Shared model configuration for the main Ora database + static func oraDatabase(isPrivate: Bool = false) -> ModelConfiguration { + if isPrivate { + return ModelConfiguration(isStoredInMemoryOnly: true) + } else { + return ModelConfiguration( + "OraData", + schema: Schema([TabContainer.self, History.self, Download.self]), + url: URL.applicationSupportDirectory.appending(path: "OraData.sqlite") + ) + } + } + + /// Creates a ModelContainer using the standard Ora database configuration + static func createOraContainer(isPrivate: Bool = false) throws -> ModelContainer { + return try ModelContainer( + for: TabContainer.self, History.self, Download.self, + configurations: oraDatabase(isPrivate: isPrivate) + ) + } +} diff --git a/ora/Common/Extensions/NSWindow+Extensions.swift b/ora/Common/Extensions/NSWindow+Extensions.swift new file mode 100644 index 00000000..e661c4e0 --- /dev/null +++ b/ora/Common/Extensions/NSWindow+Extensions.swift @@ -0,0 +1,113 @@ +import AppKit +import Foundation + +extension NSWindow { + // Private key for storing the previous frame in UserDefaults + private static let previousFrameKey = "window.previousFrame" + + /// Stores the current frame as the previous frame before maximizing + private var previousFrame: NSRect? { + get { + let defaults = UserDefaults.standard + guard let rectString = defaults.string(forKey: Self.previousFrameKey) else { return nil } + return NSRectFromString(rectString) + } + set { + let defaults = UserDefaults.standard + if let newValue { + defaults.set(NSStringFromRect(newValue), forKey: Self.previousFrameKey) + } else { + defaults.removeObject(forKey: Self.previousFrameKey) + } + } + } + + /// Toggles the window between maximized (filling the visible screen) and restored states. + /// Uses smooth animations and respects the menu bar and dock. + /// Remembers the previous frame before maximizing and restores to that exact size/position. + func toggleMaximized() { + // Get the screen's visible frame (excludes menu bar and dock) + guard let screen = self.screen else { return } + let screenFrame = screen.visibleFrame + + // Check if window is already maximized (with some tolerance for small differences) + let currentFrame = self.frame + let tolerance: CGFloat = 10 + let isMaximized = abs(currentFrame.size.width - screenFrame.size.width) < tolerance && + abs(currentFrame.size.height - screenFrame.size.height) < tolerance && + abs(currentFrame.origin.x - screenFrame.origin.x) < tolerance && + abs(currentFrame.origin.y - screenFrame.origin.y) < tolerance + + if isMaximized { + // If already maximized, restore to the previous frame if available + if let storedFrame = previousFrame { + self.setFrame(storedFrame, display: true, animate: true) + // Clear the stored frame since we're restoring + previousFrame = nil + } else { + // Fallback to default size if no previous frame is stored + let restoredWidth: CGFloat = 1440 + let restoredHeight: CGFloat = 900 + let newFrame = NSRect( + x: screenFrame.midX - restoredWidth / 2, + y: screenFrame.midY - restoredHeight / 2, + width: restoredWidth, + height: restoredHeight + ) + self.setFrame(newFrame, display: true, animate: true) + } + } else { + // Store the current frame before maximizing + previousFrame = currentFrame + // Maximize to fill the visible screen area + self.setFrame(screenFrame, display: true, animate: true) + } + } + + /// Returns true if the window is currently maximized to fill the visible screen area + var isMaximized: Bool { + guard let screen = self.screen else { return false } + let screenFrame = screen.visibleFrame + let currentFrame = self.frame + let tolerance: CGFloat = 10 + + return abs(currentFrame.size.width - screenFrame.size.width) < tolerance && + abs(currentFrame.size.height - screenFrame.size.height) < tolerance && + abs(currentFrame.origin.x - screenFrame.origin.x) < tolerance && + abs(currentFrame.origin.y - screenFrame.origin.y) < tolerance + } + + /// Maximizes the window to fill the visible screen area + /// Stores the current frame before maximizing so it can be restored later + func maximize() { + guard let screen = self.screen else { return } + let screenFrame = screen.visibleFrame + + // Store the current frame before maximizing (unless already maximized) + if !isMaximized { + previousFrame = self.frame + } + + self.setFrame(screenFrame, display: true, animate: true) + } + + /// Restores the window to a default size and centers it on the screen + /// Clears any stored previous frame since we're explicitly setting a new size + func restoreToDefaultSize() { + guard let screen = self.screen else { return } + let screenFrame = screen.visibleFrame + let restoredWidth: CGFloat = 1440 + let restoredHeight: CGFloat = 900 + let newFrame = NSRect( + x: screenFrame.midX - restoredWidth / 2, + y: screenFrame.midY - restoredHeight / 2, + width: restoredWidth, + height: restoredHeight + ) + + // Clear any stored previous frame since we're explicitly restoring to default + previousFrame = nil + + self.setFrame(newFrame, display: true, animate: true) + } +} diff --git a/ora/Common/Representables/GlobalMouseTrackingArea.swift b/ora/Common/Representables/GlobalMouseTrackingArea.swift new file mode 100644 index 00000000..3a3e7e0a --- /dev/null +++ b/ora/Common/Representables/GlobalMouseTrackingArea.swift @@ -0,0 +1,214 @@ +import SwiftUI + +enum TrackingEdge { + case left + case right + case top + case bottom +} + +struct GlobalMouseTrackingArea: NSViewRepresentable { + @Binding var mouseEntered: Bool + let edge: TrackingEdge + let padding: CGFloat + let slack: CGFloat + + init( + mouseEntered: Binding, + edge: TrackingEdge, + padding: CGFloat = 40, + slack: CGFloat = 8 + ) { + self._mouseEntered = mouseEntered + self.edge = edge + self.padding = padding + self.slack = slack + } + + func makeNSView(context: Context) -> NSView { + let view = GlobalTrackingStrip(edge: edge, padding: padding, slack: slack) + + view.onHoverChange = { hovering in + self.mouseEntered = hovering + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let strip = nsView as? GlobalTrackingStrip { + strip.edge = edge + strip.padding = padding + strip.slack = slack + } + } +} + +private final class GlobalTrackingStrip: NSView { + var edge: TrackingEdge + var padding: CGFloat + var slack: CGFloat + + #if DEBUG + private var debugWindow: NSWindow? + func showDebugOverlay(for screenRect: NSRect) { + debugWindow?.orderOut(nil) + debugWindow = nil + + let win = NSWindow( + contentRect: screenRect, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = false + win.level = .statusBar + + let overlay = NSView(frame: win.contentView!.bounds) + overlay.wantsLayer = true + overlay.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.2).cgColor + overlay.layer?.borderColor = NSColor.systemBlue.cgColor + overlay.layer?.borderWidth = 2 + win.contentView?.addSubview(overlay) + + win.orderFrontRegardless() + debugWindow = win + } + #endif + + var onHoverChange: ((Bool) -> Void)? + private var hoverTracker: GlobalHoverTracker? + + init(edge: TrackingEdge, padding: CGFloat = 40, slack: CGFloat = 8) { + self.edge = edge + self.padding = padding + self.slack = slack + super.init(frame: .zero) + self.hoverTracker = GlobalHoverTracker(view: self) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil { hoverTracker?.stop() } + super.viewWillMove(toWindow: newWindow) + } + + deinit { + hoverTracker?.stop() + } + + override func viewDidMoveToWindow() { + if window == nil { + hoverTracker?.stop() + } else { + hoverTracker?.startTracking { [weak self] inside in + guard let self else { return } + self.onHoverChange?(inside) + } + } + super.viewDidMoveToWindow() + } +} + +private class GlobalHoverTracker { + typealias LocalMonitorToken = Any + + private var localMonitor: LocalMonitorToken? + private var armed = false + private var isInside = false + + weak var view: GlobalTrackingStrip? + + init(view: GlobalTrackingStrip? = nil) { + self.view = view + } + + func startTracking(completion: @escaping (Bool) -> Void) { + guard localMonitor == nil else { return } + + var handler: ((NSEvent) -> Void)! + + handler = { [weak self] _ in + guard let self else { return } + guard let view = self.view else { return } + guard let window = view.window else { return } + + let mouse = NSEvent.mouseLocation + let screenRect = window.convertToScreen( + view.convert(view.bounds, to: nil) + ) + + let basePadding: CGFloat = armed ? view.padding : 0 + let offset: CGFloat = -1 + + // Create extended band based on edge + let band = switch view.edge { + case .left: + NSRect( + x: screenRect.minX - offset - basePadding, + y: screenRect.minY - view.slack, + width: basePadding, + height: screenRect.height + 2 * view.slack + ) + case .right: + NSRect( + x: screenRect.maxX + offset, + y: screenRect.minY - view.slack, + width: basePadding, + height: screenRect.height + 2 * view.slack + ) + case .top: + NSRect( + x: screenRect.minX - view.slack, + y: screenRect.maxY + offset, + width: screenRect.width + 2 * view.slack, + height: basePadding + ) + case .bottom: + NSRect( + x: screenRect.minX - view.slack, + y: screenRect.minY - offset - basePadding, + width: screenRect.width + 2 * view.slack, + height: basePadding + ) + } + + let insideBase = screenRect.contains(mouse) + let inBand = band.contains(mouse) + let effective = insideBase || inBand + +// #if DEBUG +// DispatchQueue.main.async { +// view.showDebugOverlay(for: band) +// } +// #endif + + if effective != isInside { + isInside = effective + armed = effective + if Thread.isMainThread { + completion(effective) + } else { + DispatchQueue.main.async { completion(effective) } + } + } + } + + localMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.mouseMoved] + ) { event in + handler(event) + return event + } + } + + func stop() { + if let local = localMonitor { NSEvent.removeMonitor(local) } + localMonitor = nil + isInside = false + } +} diff --git a/ora/Common/Representables/MouseTrackingArea.swift b/ora/Common/Representables/MouseTrackingArea.swift deleted file mode 100644 index 8871fcb2..00000000 --- a/ora/Common/Representables/MouseTrackingArea.swift +++ /dev/null @@ -1,169 +0,0 @@ -import SwiftUI - -struct MouseTrackingArea: NSViewRepresentable { - @Binding var mouseEntered: Bool - - func makeNSView(context: Context) -> NSView { - let view = TrackingStrip() - - /// No Need To Pass We Can handle it nicely with closures - view.onHoverChange = { hovering in - self.mouseEntered = hovering - } - - return view - } - - func updateNSView(_ nsView: NSView, context: Context) {} -} - -private final class TrackingStrip: NSView { - #if DEBUG - private var debugWindow: NSWindow? - func showDebugOverlay(for screenRect: NSRect) { - debugWindow?.orderOut(nil) - debugWindow = nil - - /// Make a borderless, transparent window at the rect - let win = NSWindow( - contentRect: screenRect, - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - win.isOpaque = false - win.backgroundColor = .clear - win.hasShadow = false - win.level = .statusBar - - /// colored view - let overlay = NSView(frame: win.contentView!.bounds) - overlay.wantsLayer = true - overlay.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.2).cgColor - overlay.layer?.borderColor = NSColor.systemGreen.cgColor - overlay.layer?.borderWidth = 2 - win.contentView?.addSubview(overlay) - - win.orderFrontRegardless() - debugWindow = win - } - #endif - - var onHoverChange: ((Bool) -> Void)? - - private var hoverTracker: HoverTracker? - - init() { - super.init(frame: .zero) - self.hoverTracker = HoverTracker(view: self) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - override func viewWillMove(toWindow newWindow: NSWindow?) { - if newWindow == nil { hoverTracker?.stop() } - super.viewWillMove(toWindow: newWindow) - } - - deinit { - hoverTracker?.stop() - } - - override func viewDidMoveToWindow() { - if window == nil { - hoverTracker?.stop() - } else { - hoverTracker?.startTracking { [weak self] inside in - guard let self else { return } - self.onHoverChange?(inside) - } - } - super.viewDidMoveToWindow() - } - - class HoverTracker { - typealias LocalMonitorToken = Any - - private var localMonitor: LocalMonitorToken? - - private var armed = false - private var isInside = false - - weak var view: TrackingStrip? - - /// Maybe Configurable inside settings or tuned to liking? - let padding: CGFloat = 40 - let verticalSlack: CGFloat = 8 // extra Y tolerance - - init( - view: TrackingStrip? = nil - ) { - self.view = view - } - - func startTracking(completion: @escaping (Bool) -> Void) { - guard localMonitor == nil else { return } - - var handler: ((NSEvent) -> Void)! - - handler = { [weak self] _ in - guard let self else { return } - guard let view = self.view else { return } - guard let window = view.window else { return } - - /// Global Screen Coordinates - let mouse = NSEvent.mouseLocation - - /// This is where the view is - let screenRect = window.convertToScreen(view.convert(view.bounds, to: nil)) - - /* - Maybe wanna let a bit of the left allow the sidebar to show: - Move offset + - Also Make baseWidth negation something larger - */ - /// If we are showing the sidebar THEN we grow the size - let baseWidth: CGFloat = armed ? padding : 0 - /// - goes to right, + goes to left, I like -1 - let offset: CGFloat = -1 - - let leftBand = NSRect( - x: screenRect.minX - offset - baseWidth, - y: screenRect.minY - verticalSlack, - width: baseWidth, - height: screenRect.height + 2 * verticalSlack - ) - - let insideBase = screenRect.contains(mouse) - let inLeftBand = leftBand.contains(mouse) - - let effective = insideBase || inLeftBand - -// #if DEBUG -// /// UNCOMMENT IF WANNA SEE RECT GETTING HIDDEN/SHOWN -// DispatchQueue.main.async { -// view.showDebugOverlay(for: leftBand) -// } -// #endif - - if effective != isInside { - isInside = effective - armed = effective - if Thread.isMainThread { completion(effective) } - else { DispatchQueue.main.async { completion(effective) } } - } - } - - localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { event in handler(event) - return event - } - } - - func stop() { - if let local = localMonitor { NSEvent.removeMonitor(local) } - localMonitor = nil - isInside = false - } - } -} diff --git a/ora/Common/Representables/WindowAccessor.swift b/ora/Common/Representables/WindowAccessor.swift index 2605e695..5e510956 100644 --- a/ora/Common/Representables/WindowAccessor.swift +++ b/ora/Common/Representables/WindowAccessor.swift @@ -2,13 +2,8 @@ import AppKit import SwiftUI struct WindowAccessor: NSViewRepresentable { - let isSidebarHidden: Bool - @Binding var isFloatingSidebar: Bool @Binding var isFullscreen: Bool - // Store original button frames to restore them later - private static var originalButtonFrames: [NSWindow.ButtonType: NSRect] = [:] - func makeCoordinator() -> Coordinator { Coordinator(self) } @@ -21,13 +16,13 @@ struct WindowAccessor: NSViewRepresentable { self.parent = parent } - @objc func didEnterFullScreen(_ notification: Notification) { + @objc func willEnterFullScreenNotification(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } parent.isFullscreen = true parent.updateTrafficLights(for: window) } - @objc func didExitFullScreen(_ notification: Notification) { + @objc func willExitFullScreenNotification(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } parent.isFullscreen = false parent.updateTrafficLights(for: window) @@ -44,17 +39,17 @@ struct WindowAccessor: NSViewRepresentable { let coordinator = context.coordinator let enterObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didEnterFullScreenNotification, + forName: NSWindow.willEnterFullScreenNotification, object: window, queue: nil, - using: coordinator.didEnterFullScreen + using: coordinator.willEnterFullScreenNotification ) let exitObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didExitFullScreenNotification, + forName: NSWindow.willExitFullScreenNotification, object: window, queue: nil, - using: coordinator.didExitFullScreen + using: coordinator.willExitFullScreenNotification ) coordinator.observers = [enterObserver, exitObserver] @@ -76,33 +71,13 @@ struct WindowAccessor: NSViewRepresentable { } private func updateTrafficLights(for window: NSWindow) { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.25 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - for buttonType in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { - guard let button = window.standardWindowButton(buttonType) else { continue } - - // Store original frame if we haven't already - if WindowAccessor.originalButtonFrames[buttonType] == nil { - WindowAccessor.originalButtonFrames[buttonType] = button.frame - } - - if let originalFrame = WindowAccessor.originalButtonFrames[buttonType] { - if isSidebarHidden, !isFullscreen { - // Always offset when sidebar is hidden - var newFrame = originalFrame - newFrame.origin.x += 8 - newFrame.origin.y -= 8 - button.animator().setFrameOrigin(newFrame.origin) - } else { - // Restore to original frame when visible - button.animator().setFrameOrigin(originalFrame.origin) - } - } - - button.animator().isHidden = (isSidebarHidden && !isFloatingSidebar && !isFullscreen) - } + for type in [ + NSWindow.ButtonType.closeButton, + .miniaturizeButton, + .zoomButton + ] { + guard let button = window.standardWindowButton(type) else { continue } + button.animator().isHidden = !isFullscreen } } } diff --git a/ora/Common/Utils/SettingsStore.swift b/ora/Common/Utils/SettingsStore.swift index a34a11d9..821146b6 100644 --- a/ora/Common/Utils/SettingsStore.swift +++ b/ora/Common/Utils/SettingsStore.swift @@ -35,6 +35,7 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { let aliases: [String] let faviconData: Data? let faviconBackgroundColorData: Data? + let isAIChat: Bool init( id: String = UUID().uuidString, @@ -42,7 +43,8 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { searchURL: String, aliases: [String] = [], faviconData: Data? = nil, - faviconBackgroundColorData: Data? = nil + faviconBackgroundColorData: Data? = nil, + isAIChat: Bool = false ) { self.id = id self.name = name @@ -50,6 +52,7 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { self.aliases = aliases self.faviconData = faviconData self.faviconBackgroundColorData = faviconBackgroundColorData + self.isAIChat = isAIChat } var favicon: NSImage? { @@ -72,9 +75,10 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { name: String, searchURL: String, aliases: [String] = [], + isAIChat: Bool = false, completion: @escaping (CustomSearchEngine) -> Void ) { - let faviconService = FaviconService() + let faviconService = FaviconService.shared // Try to fetch favicon synchronously first (from cache) if let favicon = faviconService.getFavicon(for: searchURL) { @@ -91,7 +95,8 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { searchURL: searchURL, aliases: aliases, faviconData: faviconData, - faviconBackgroundColorData: colorData + faviconBackgroundColorData: colorData, + isAIChat: isAIChat ) completion(engine) } else { @@ -116,7 +121,8 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { searchURL: searchURL, aliases: aliases, faviconData: faviconData, - faviconBackgroundColorData: colorData + faviconBackgroundColorData: colorData, + isAIChat: isAIChat ) completion(engine) } @@ -140,6 +146,10 @@ class SettingsStore: ObservableObject { private let customSearchEnginesKey = "settings.customSearchEngines" private let globalDefaultSearchEngineKey = "settings.globalDefaultSearchEngine" private let customKeyboardShortcutsKey = "settings.customKeyboardShortcuts" + private let tabAliveTimeoutKey = "settings.tabAliveTimeout" + private let tabRemovalTimeoutKey = "settings.tabRemovalTimeout" + private let maxRecentTabsKey = "settings.maxRecentTabs" + private let autoPiPEnabledKey = "settings.autoPiPEnabled" // MARK: - Per-Container @@ -191,6 +201,22 @@ class SettingsStore: ObservableObject { didSet { saveCodable(customKeyboardShortcuts, forKey: customKeyboardShortcutsKey) } } + @Published var tabAliveTimeout: TimeInterval { + didSet { defaults.set(tabAliveTimeout, forKey: tabAliveTimeoutKey) } + } + + @Published var tabRemovalTimeout: TimeInterval { + didSet { defaults.set(tabRemovalTimeout, forKey: tabRemovalTimeoutKey) } + } + + @Published var maxRecentTabs: Int { + didSet { defaults.set(maxRecentTabs, forKey: maxRecentTabsKey) } + } + + @Published var autoPiPEnabled: Bool { + didSet { defaults.set(autoPiPEnabled, forKey: autoPiPEnabledKey) } + } + init() { autoUpdateEnabled = defaults.bool(forKey: autoUpdateKey) blockThirdPartyTrackers = defaults.bool(forKey: trackingThirdPartyKey) @@ -214,6 +240,37 @@ class SettingsStore: ObservableObject { customKeyboardShortcuts = Self.loadCodable([String: KeyChord].self, key: customKeyboardShortcutsKey) ?? [:] + + let aliveTimeoutValue = defaults.double(forKey: tabAliveTimeoutKey) + let supportedTimeouts: [TimeInterval] = [ + 60 * 60, // 1 hour + 6 * 60 * 60, // 6 hours + 12 * 60 * 60, // 12 hours + 24 * 60 * 60, // 1 day + 2 * 24 * 60 * 60, // 2 days + 365 * 24 * 60 * 60 // "Never" sentinel + ] + let normalizedAlive = Self.normalizeTimeout( + aliveTimeoutValue, + defaultSeconds: 60 * 60, + supported: supportedTimeouts + ) + defaults.set(normalizedAlive, forKey: tabAliveTimeoutKey) + tabAliveTimeout = normalizedAlive + + let removalTimeoutValue = defaults.double(forKey: tabRemovalTimeoutKey) + let normalizedRemoval = Self.normalizeTimeout( + removalTimeoutValue, + defaultSeconds: 24 * 60 * 60, + supported: supportedTimeouts + ) + defaults.set(normalizedRemoval, forKey: tabRemovalTimeoutKey) + tabRemovalTimeout = normalizedRemoval + + let maxRecentTabsValue = defaults.integer(forKey: maxRecentTabsKey) + maxRecentTabs = maxRecentTabsValue == 0 ? 5 : maxRecentTabsValue + + autoPiPEnabled = defaults.object(forKey: autoPiPEnabledKey) as? Bool ?? true } // MARK: - Per-container helpers @@ -312,4 +369,22 @@ class SettingsStore: ObservableObject { guard let data = defaults.data(forKey: key) else { return nil } return try? JSONDecoder().decode(T.self, from: data) } + + // MARK: - Normalization helpers + + private static func normalizeTimeout( + _ raw: TimeInterval, + defaultSeconds: TimeInterval, + supported: [TimeInterval] + ) -> TimeInterval { + let value: TimeInterval = raw == 0 ? defaultSeconds : raw + + if supported.contains(value) { + return value + } + + return supported.min { lhs, rhs in + abs(lhs - value) < abs(rhs - value) + } ?? defaultSeconds + } } diff --git a/ora/Common/Utils/WindowFactory.swift b/ora/Common/Utils/WindowFactory.swift new file mode 100644 index 00000000..15ee5251 --- /dev/null +++ b/ora/Common/Utils/WindowFactory.swift @@ -0,0 +1,23 @@ +import AppKit +import SwiftUI + +enum WindowFactory { + static func makeMainWindow(rootView: some View, size: CGSize = CGSize(width: 1440, height: 900)) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isReleasedWhenClosed = false + + let hostingController = NSHostingController(rootView: rootView) + window.contentViewController = hostingController + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return window + } +} diff --git a/ora/Info.plist b/ora/Info.plist index a0ce0850..3b13bf3a 100644 --- a/ora/Info.plist +++ b/ora/Info.plist @@ -4,6 +4,45 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDocumentTypes + + + CFBundleTypeName + Web URL + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + public.url + + + + CFBundleTypeName + HTML document + CFBundleTypeRole + Viewer + LSHandlerRank + Default + LSItemContentTypes + + public.html + + + + CFBundleTypeName + XHTML document + CFBundleTypeRole + Viewer + LSHandlerRank + Default + LSItemContentTypes + + public.xhtml + + + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -16,8 +55,32 @@ APPL CFBundleShortVersionString 1.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + Ora Browser + CFBundleURLSchemes + + http + https + + LSHandlerRank + Owner + + CFBundleVersion 1 + LSApplicationCategoryType + public.app-category.web-browser + LSMinimumSystemVersion + 13.0 + NSUserActivityTypes + + NSUserActivityTypeBrowsingWeb + SUEnableAutomaticChecks SUFeedURL diff --git a/ora/Models/SearchEngine.swift b/ora/Models/SearchEngine.swift index d55b6713..a04351bf 100644 --- a/ora/Models/SearchEngine.swift +++ b/ora/Models/SearchEngine.swift @@ -34,7 +34,6 @@ struct SearchEngine { extension SearchEngine { func toLauncherMatch( originalAlias: String, - faviconService: FaviconService? = nil, customEngine: CustomSearchEngine? = nil ) -> LauncherMain.Match { var favicon: NSImage? @@ -46,8 +45,8 @@ extension SearchEngine { faviconColor = customEngine.faviconBackgroundColor } else { // For built-in engines, use favicon service - favicon = faviconService?.getFavicon(for: searchURL) - faviconColor = faviconService?.getFaviconColor(for: searchURL) + favicon = FaviconService.shared.getFavicon(for: searchURL) + faviconColor = FaviconService.shared.getFaviconColor(for: searchURL) } return LauncherMain.Match( diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index bed015f5..52f47f31 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -3,6 +3,8 @@ import SwiftData import SwiftUI import WebKit +// Import for accessing settings + enum TabType: String, Codable { case pinned case fav @@ -27,14 +29,15 @@ class Tab: ObservableObject, Identifiable { var favicon: URL? // Add favicon property var createdAt: Date var lastAccessedAt: Date? - var isPlayingMedia: Bool - var isLoading: Bool = false + var type: TabType var order: Int var faviconLocalFile: URL? var backgroundColorHex: String = "#000000" // @Transient @Published var backgroundColor: Color = Color(.black) + @Transient var isPlayingMedia: Bool = false + @Transient var isLoading: Bool = false @Transient @Published var backgroundColor: Color = .black @Transient var historyManager: HistoryManager? @Transient var downloadManager: DownloadManager? @@ -51,9 +54,17 @@ class Tab: ObservableObject, Identifiable { @Transient @Published var failedURL: URL? @Transient @Published var hoveredLinkURL: String? @Transient var isPrivate: Bool = false + @Transient var extensionTabWrapper: ExtensionTabWrapper? @Relationship(inverse: \TabContainer.tabs) var container: TabContainer + /// Whether this tab is considered alive (recently accessed) + var isAlive: Bool { + guard let lastAccessed = lastAccessedAt else { return false } + let timeout = SettingsStore.shared.tabAliveTimeout + return Date().timeIntervalSince(lastAccessed) < timeout + } + init( id: UUID = UUID(), url: URL, @@ -101,6 +112,7 @@ class Tab: ObservableObject, Identifiable { config.tab = self config.mediaController = tabManager.mediaController + self.attachExtension() // Configure WebView for performance webView.allowsMagnification = true webView.allowsBackForwardNavigationGestures = true @@ -133,8 +145,8 @@ class Tab: ObservableObject, Identifiable { func setFavicon(faviconURLDefault: URL? = nil) { guard let host = self.url.host else { return } - let faviconURL = faviconURLDefault != nil ? faviconURLDefault! : - URL(string: "https://www.google.com/s2/favicons?domain=\(host)")! + let domain = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + let faviconURL = faviconURLDefault ?? URL(string: "https://www.google.com/s2/favicons?domain=\(domain)")! self.favicon = faviconURL // Infer extension from URL or fallback to png @@ -142,21 +154,24 @@ class Tab: ObservableObject, Identifiable { let fileName = "\(self.id.uuidString).\(ext)" let saveURL = FileManager.default.faviconDirectory.appendingPathComponent(fileName) - Task { - do { - let (data, _) = try await URLSession.shared.data(from: faviconURL) - try data.write(to: saveURL, options: .atomic) - + FaviconService.shared.downloadAndSaveFavicon(for: domain, to: saveURL) { sourceURL, success in + if success { self.faviconLocalFile = saveURL - - } catch { - // Failed to download/save favicon + if let sourceURL { + self.favicon = sourceURL + } } } } func switchSections(from: Tab, to: Tab) { from.type = to.type + switch to.type { + case .pinned, .fav: + from.savedURL = from.url + case .normal: + from.savedURL = nil + } } func updateHeaderColor() { @@ -204,6 +219,9 @@ class Tab: ObservableObject, Identifiable { DispatchQueue.main.async { if let title, !title.isEmpty { self?.title = title + if let self { + self.tabManager?.mediaController.syncTitleForTab(self.id, newTitle: title) + } } } } @@ -236,21 +254,24 @@ class Tab: ObservableObject, Identifiable { } func goForward() { + lastAccessedAt = Date() self.webView.goForward() self.updateHeaderColor() } func goBack() { + lastAccessedAt = Date() self.webView.goBack() self.updateHeaderColor() } func restoreTransientState( - historyManger: HistoryManager, + historyManager: HistoryManager, downloadManager: DownloadManager, tabManager: TabManager, isPrivate: Bool ) { + self.attachExtension() // Avoid double initialization if webView.url != nil { return } @@ -275,7 +296,7 @@ class Tab: ObservableObject, Identifiable { layer.drawsAsynchronously = true } - self.historyManager = historyManger + self.historyManager = historyManager self.downloadManager = downloadManager self.tabManager = tabManager self.isWebViewReady = false @@ -283,7 +304,8 @@ class Tab: ObservableObject, Identifiable { self.syncBackgroundColorFromHex() // Load after a short delay to ensure layout DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - self.webView.load(URLRequest(url: self.url)) + let url = if self.type != .normal { self.savedURL } else { self.url } + self.webView.load(URLRequest(url: url ?? self.url)) self.isWebViewReady = true } } @@ -309,6 +331,7 @@ class Tab: ObservableObject, Identifiable { } func loadURL(_ urlString: String) { + lastAccessedAt = Date() let input = urlString.trimmingCharacters(in: .whitespacesAndNewlines) // 1) Try to construct a direct URL (has scheme or valid domain+TLD/IP) @@ -349,12 +372,18 @@ class Tab: ObservableObject, Identifiable { } func destroyWebView() { + if let wrapper = extensionTabWrapper { + print("[Tab] destroyWebView didCloseTab wrapperId=\(wrapper.id) tabId=\(id.uuidString)") + ExtensionManager.shared.controller.didCloseTab(wrapper) + extensionTabWrapper = nil + } webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil webView.configuration.userContentController.removeAllUserScripts() webView.removeFromSuperview() webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + isWebViewReady = false } func setNavigationError(_ error: Error, for url: URL?) { @@ -381,6 +410,21 @@ class Tab: ObservableObject, Identifiable { webView.load(request) } } + func attachExtension(){ + if extensionTabWrapper == nil { + let newId = ExtensionManager.shared.nextTabId() + print("[Tab] attachExtension creating wrapper wrapperId=\(newId) tabId=\(id.uuidString)") + let wrapper = ExtensionTabWrapper(nativeTab: self, id: newId) + extensionTabWrapper = wrapper + Task { @MainActor in + ExtensionManager.shared.ensureWindowOpened() + ExtensionManager.shared.controller.didOpenTab(wrapper) + print("[ExtMgr] didOpenTab wrapperId=\(newId)") + } + } else { + print("[Tab] attachExtension skipped (already attached) tabId=\(id.uuidString) wrapperId=\(extensionTabWrapper?.id ?? -1)") + } + } } extension FileManager { diff --git a/ora/Modules/Browser/BrowserContentContainer.swift b/ora/Modules/Browser/BrowserContentContainer.swift index 09f8cdea..0e8d86dc 100644 --- a/ora/Modules/Browser/BrowserContentContainer.swift +++ b/ora/Modules/Browser/BrowserContentContainer.swift @@ -2,34 +2,35 @@ import SwiftUI struct BrowserContentContainer: View { @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var appState: AppState + @EnvironmentObject var sidebarManager: SidebarManager + let content: () -> Content - let isFullscreen: Bool - let hideState: SideHolder - let sidebarCornerRadius: CGFloat = { + private var isCompleteFullscreen: Bool { + appState.isFullscreen && sidebarManager.isSidebarHidden + } + + private var cornerRadius: CGFloat { if #available(macOS 26, *) { - return 8 + return 13 } else { return 6 } - }() + } - init(isFullscreen: Bool, hideState: SideHolder, @ViewBuilder content: @escaping () -> Content) { - self.isFullscreen = isFullscreen - self.hideState = hideState + init( + @ViewBuilder content: @escaping () -> Content + ) { self.content = content } var body: some View { content() .frame(maxWidth: .infinity, maxHeight: .infinity) - .clipShape( - ConditionallyConcentricRectangle( - cornerRadius: isFullscreen && hideState.side == .primary ? 0 : sidebarCornerRadius - ) - ) + .clipShape(RoundedRectangle(cornerRadius: isCompleteFullscreen ? 0 : cornerRadius, style: .continuous)) .padding( - isFullscreen && hideState.side == .primary + isCompleteFullscreen ? EdgeInsets( top: 0, leading: 0, @@ -38,12 +39,15 @@ struct BrowserContentContainer: View { ) : EdgeInsets( top: 6, - leading: hideState.side == .primary ? 6 : 0, + leading: sidebarManager.sidebarPosition != .primary || sidebarManager.hiddenSidebar + .side == .primary ? 6 : 0, bottom: 6, - trailing: 6 + trailing: sidebarManager.sidebarPosition != .secondary || sidebarManager.hiddenSidebar + .side == .secondary ? 6 : 0 ) ) - .shadow(color: .black.opacity(0.15), radius: sidebarCornerRadius, x: 0, y: 2) + .animation(.easeInOut(duration: 0.3), value: appState.isFullscreen) + .shadow(color: .black.opacity(0.15), radius: isCompleteFullscreen ? 0 : cornerRadius, x: 0, y: 2) .ignoresSafeArea(.all) } } diff --git a/ora/Modules/Browser/BrowserSplitView.swift b/ora/Modules/Browser/BrowserSplitView.swift new file mode 100644 index 00000000..c2c1c366 --- /dev/null +++ b/ora/Modules/Browser/BrowserSplitView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct BrowserSplitView: View { + @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager + @EnvironmentObject var sidebarManager: SidebarManager + + private var targetSide: SplitSide { + sidebarManager.sidebarPosition == .primary ? .primary : .secondary + } + + private var splitFraction: FractionHolder { + sidebarManager.sidebarPosition == .primary + ? sidebarManager.currentFraction + : sidebarManager.currentFraction.inverted() + } + + private var minPF: CGFloat { + sidebarManager.sidebarPosition == .primary ? 0.16 : 0.7 + } + + private var minSF: CGFloat { + sidebarManager.sidebarPosition == .primary ? 0.7 : 0.16 + } + + private var prioritySide: SplitSide { + sidebarManager.sidebarPosition == .primary ? .primary : .secondary + } + + private var dragToHidePFlag: Bool { + sidebarManager.sidebarPosition == .primary + } + + private var dragToHideSFlag: Bool { + sidebarManager.sidebarPosition == .secondary + } + + var body: some View { + HSplit(left: { primaryPane() }, right: { secondaryPane() }) + .hide(sidebarManager.hiddenSidebar) + .splitter { Splitter.invisible() } + .fraction(splitFraction) + .constraints( + minPFraction: minPF, + minSFraction: minSF, + priority: prioritySide, + dragToHideP: dragToHidePFlag, + dragToHideS: dragToHideSFlag + ) + .styling(hideSplitter: true) + } + + @ViewBuilder + private func primaryPane() -> some View { + paneContent( + isSidebarPane: sidebarManager.sidebarPosition == .primary, + isOtherPaneHidden: sidebarManager.hiddenSidebar.side == .secondary + ) + } + + @ViewBuilder + private func secondaryPane() -> some View { + paneContent( + isSidebarPane: sidebarManager.sidebarPosition == .secondary, + isOtherPaneHidden: sidebarManager.hiddenSidebar.side == .primary + ) + } + + @ViewBuilder + private func paneContent(isSidebarPane: Bool, isOtherPaneHidden: Bool) -> some View { + if isSidebarPane, !isOtherPaneHidden { + SidebarView() + } else { + contentView() + } + } + + @ViewBuilder + private func contentView() -> some View { + if tabManager.activeTab == nil { + BrowserContentContainer { + HomeView() + } + } + ZStack { + let activeId = tabManager.activeTab?.id + ForEach(tabManager.tabsToRender) { tab in + if tab.isWebViewReady { + BrowserContentContainer { + BrowserWebContentView(tab: tab) + } + .opacity(tab.id == activeId ? 1 : 0) + .allowsHitTesting(tab.id == activeId) + } + } + } + } +} diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 0add642f..4fb1d2b9 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -2,167 +2,76 @@ import AppKit import SwiftUI struct BrowserView: View { - @EnvironmentObject var tabManager: TabManager @Environment(\.theme) var theme + @EnvironmentObject var tabManager: TabManager @EnvironmentObject private var appState: AppState @EnvironmentObject private var downloadManager: DownloadManager @EnvironmentObject private var historyManager: HistoryManager @EnvironmentObject private var privacyMode: PrivacyMode - @State private var isFullscreen = false - @State private var showFloatingSidebar = false - @State private var isMouseOverSidebar = false - @StateObject private var sidebarFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction") - - @StateObject var sidebarVisibility = SideHolder() + @EnvironmentObject private var sidebarManager: SidebarManager + @EnvironmentObject private var toolbarManager: ToolbarManager - private func toggleSidebar() { - withAnimation(.spring(response: 0.2, dampingFraction: 1.0)) { - sidebarVisibility.toggle(.primary) - } - } + @State private var isMouseOverURLBar = false + @State private var showFloatingURLBar = false + @State private var isMouseOverSidebar = false + @State private var showFloatingSidebar = false var body: some View { - ZStack(alignment: .leading) { - HSplit( - left: { - SidebarView(isFullscreen: isFullscreen) - }, - right: { - if tabManager.activeTab != nil { - BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { - webView - } - } else { - // Start page (visible when no tab is active) - BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { - HomeView(sidebarToggle: toggleSidebar) - } - } - } - ) - .hide(sidebarVisibility) - .splitter { Splitter.invisible() } - .fraction(sidebarFraction) - .constraints( - minPFraction: 0.16, - minSFraction: 0.7, - priority: .left, - dragToHideP: true - ) - // In autohide mode, remove any draggable splitter area to unhide - .styling(hideSplitter: true) - .ignoresSafeArea(.all) - .background(theme.subtleWindowBackgroundColor) - .background( - BlurEffectView( - material: .underWindowBackground, - blendingMode: .behindWindow - ).ignoresSafeArea(.all) - ) - .background( - WindowAccessor( - isSidebarHidden: sidebarVisibility.side == .primary, - isFloatingSidebar: $showFloatingSidebar, - isFullscreen: $isFullscreen + ZStack(alignment: .top) { + BrowserSplitView() + .ignoresSafeArea(.all) + .background(theme.subtleWindowBackgroundColor) + .background( + BlurEffectView(material: .underWindowBackground, blendingMode: .behindWindow) + .ignoresSafeArea(.all) ) - .id("showFloatingSidebar = \(showFloatingSidebar)") // Forces WindowAccessor to update (for Traffic - // Lights) - ) - .overlay { - if appState.showLauncher, tabManager.activeTab != nil { - LauncherView() + .overlay { + if appState.showLauncher, tabManager.activeTab != nil { + LauncherView() + } + if appState.isFloatingTabSwitchVisible { + FloatingTabSwitcher() + } } - if appState.isFloatingTabSwitchVisible { - FloatingTabSwitcher() - } + if sidebarManager.isSidebarHidden { + FloatingSidebarOverlay( + showFloatingSidebar: $showFloatingSidebar, + isMouseOverSidebar: $isMouseOverSidebar, + sidebarFraction: sidebarManager.currentFraction, + isDownloadsPopoverOpen: downloadManager.isDownloadsPopoverOpen + ) } - if sidebarVisibility.side == .primary { - // Floating sidebar with resizable width based on persisted fraction - GeometryReader { geo in - let totalWidth = geo.size.width - let minFraction: CGFloat = 0.16 - let maxFraction: CGFloat = 0.30 - let clampedFraction = min(max(sidebarFraction.value, minFraction), maxFraction) - let floatingWidth = max(0, min(totalWidth * clampedFraction, totalWidth)) - ZStack(alignment: .leading) { - if showFloatingSidebar { - FloatingSidebar(isFullscreen: isFullscreen) - .frame(width: floatingWidth) - .transition(.move(edge: .leading)) - .overlay(alignment: .trailing) { - // Invisible resize handle to adjust width in autohide mode - Rectangle() - .fill(Color.clear) - .frame(width: 14) - #if targetEnvironment(macCatalyst) || os(macOS) - .cursor(NSCursor.resizeLeftRight) - #endif - .contentShape(Rectangle()) - .gesture( - DragGesture() - .onChanged { value in - let proposedWidth = max( - 0, - min(floatingWidth + value.translation.width, totalWidth) - ) - let newFraction = proposedWidth / max(totalWidth, 1) - // Clamp to same constraints as HSplit - sidebarFraction.value = min( - max(newFraction, minFraction), - maxFraction - ) - } - ) - } - .zIndex(3) - } - // Hover tracking strip to show/hide floating sidebar - Color.clear - .frame(width: showFloatingSidebar ? floatingWidth : 10) - .overlay( - MouseTrackingArea( - mouseEntered: Binding( - get: { showFloatingSidebar }, - set: { newValue in - isMouseOverSidebar = newValue - // Don't hide sidebar if downloads popover is open - if !newValue, downloadManager.isDownloadsPopoverOpen { - return - } - showFloatingSidebar = newValue - } - ) - ) - ) - .zIndex(2) - } - } + if toolbarManager.isToolbarHidden { + FloatingURLBar( + showFloatingURLBar: $showFloatingURLBar, + isMouseOverURLBar: $isMouseOverURLBar + ) } } .edgesIgnoringSafeArea(.all) .animation(.easeOut(duration: 0.1), value: showFloatingSidebar) .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in - toggleSidebar() + sidebarManager.toggleSidebar() + } + .onReceive(NotificationCenter.default.publisher(for: .toggleSidebarPosition)) { _ in + sidebarManager.toggleSidebarPosition() } - .onChange(of: downloadManager.isDownloadsPopoverOpen) { isOpen in - if sidebarVisibility.side == .primary { + .onChange(of: downloadManager.isDownloadsPopoverOpen) { _, isOpen in + if sidebarManager.isSidebarHidden { if isOpen { - // Keep sidebar visible while downloads popover is open showFloatingSidebar = true } else if !isMouseOverSidebar { - // Hide sidebar when popover closes and mouse is not over sidebar showFloatingSidebar = false } } } - .onChange(of: tabManager.activeTab) { newTab in - // Restore tab state when switching tabs via keyboard shortcut + .onChange(of: tabManager.activeTab) { _, newTab in if let tab = newTab, !tab.isWebViewReady { DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { tab.restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -170,61 +79,15 @@ struct BrowserView: View { } } } - } - - @ViewBuilder - private var webView: some View { - VStack(alignment: .leading, spacing: 0) { - if !appState.isToolbarHidden { - URLBar( - onSidebarToggle: { toggleSidebar() } - ) - .transition(.asymmetric( - insertion: .push(from: .top), - removal: .push(from: .bottom) - )) - } - if let tab = tabManager.activeTab { - if tab.isWebViewReady { - if tab.hasNavigationError, let error = tab.navigationError { - StatusPageView( - error: error, - failedURL: tab.failedURL, - onRetry: { - tab.retryNavigation() - }, - onGoBack: tab.webView.canGoBack - ? { - tab.webView.goBack() - tab.clearNavigationError() - } : nil - ) - .id(tab.id) - } else { - ZStack(alignment: .topTrailing) { - WebView(webView: tab.webView) - .id(tab.id) - - if appState.showFinderIn == tab.id { - FindView(webView: tab.webView) - .padding(.top, 16) - .padding(.trailing, 16) - .zIndex(1000) - } - - if let hovered = tab.hoveredLinkURL, !hovered.isEmpty { - LinkPreview(text: hovered) - } - } - } - } else { - ZStack { - Rectangle() - .fill(theme.background) - - ProgressView().frame(width: 32, height: 32) - - }.frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + if let tab = tabManager.activeTab, !tab.isWebViewReady { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + tab.restoreTransientState( + historyManager: historyManager, + downloadManager: downloadManager, + tabManager: tabManager, + isPrivate: privacyMode.isPrivate + ) } } } diff --git a/ora/Modules/Browser/BrowserWebContentView.swift b/ora/Modules/Browser/BrowserWebContentView.swift new file mode 100644 index 00000000..6ca524fe --- /dev/null +++ b/ora/Modules/Browser/BrowserWebContentView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct BrowserWebContentView: View { + @Environment(\.theme) var theme + @EnvironmentObject var tabManager: TabManager + @EnvironmentObject private var appState: AppState + @EnvironmentObject private var toolbarManager: ToolbarManager + let tab: Tab + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if !toolbarManager.isToolbarHidden { + URLBar( + onSidebarToggle: { + NotificationCenter.default.post( + name: .toggleSidebar, object: nil + ) + } + ) + .transition( + .asymmetric( + insertion: .push(from: .top), + removal: .push(from: .bottom) + ) + ) + } + + if tab.isWebViewReady { + if tab.hasNavigationError, let error = tab.navigationError { + StatusPageView( + error: error, + failedURL: tab.failedURL, + onRetry: { tab.retryNavigation() }, + onGoBack: tab.webView.canGoBack + ? { + tab.webView.goBack() + tab.clearNavigationError() + } : nil + ) + .id(tab.id) + } else { + ZStack(alignment: .topTrailing) { + WebView(webView: tab.webView).id(tab.id) + + if appState.showFinderIn == tab.id { + FindView(webView: tab.webView) + .padding(.top, 16) + .padding(.trailing, 16) + .zIndex(1000) + } + + if let hovered = tab.hoveredLinkURL, !hovered.isEmpty { + LinkPreview(text: hovered) + } + } + } + } else { + ZStack { + Rectangle().fill(theme.background) + ProgressView().frame(width: 32, height: 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} diff --git a/ora/Modules/Browser/FloatingSidebarOverlay.swift b/ora/Modules/Browser/FloatingSidebarOverlay.swift new file mode 100644 index 00000000..71f27c9b --- /dev/null +++ b/ora/Modules/Browser/FloatingSidebarOverlay.swift @@ -0,0 +1,117 @@ +import SwiftUI + +struct FloatingSidebarOverlay: View { + @EnvironmentObject private var sidebarManager: SidebarManager + + @Binding var showFloatingSidebar: Bool + @Binding var isMouseOverSidebar: Bool + + var sidebarFraction: FractionHolder + let isDownloadsPopoverOpen: Bool + + @State private var dragFraction: CGFloat? + + var body: some View { + GeometryReader { geo in + let totalWidth = geo.size.width + let minFraction: CGFloat = 0.16 + let maxFraction: CGFloat = 0.30 + let currentFraction = dragFraction ?? sidebarFraction.value + let clampedFraction = min(max(currentFraction, minFraction), maxFraction) + let floatingWidth = max(0, min(totalWidth * clampedFraction, totalWidth)) + + ZStack(alignment: sidebarManager.sidebarPosition == .primary ? .leading : .trailing) { + if showFloatingSidebar { + FloatingSidebar() + .frame(width: floatingWidth) + .transition(.move(edge: sidebarManager.sidebarPosition == .primary ? .leading : .trailing)) + .overlay(alignment: sidebarManager.sidebarPosition == .primary ? .trailing : .leading) { + ResizeHandle( + dragFraction: $dragFraction, + sidebarFraction: sidebarFraction, + sidebarPosition: sidebarManager.sidebarPosition, + floatingWidth: floatingWidth, + totalWidth: totalWidth, + minFraction: minFraction, + maxFraction: maxFraction + ) + } + .zIndex(3) + } + + HStack(spacing: 0) { + if sidebarManager.sidebarPosition == .primary { + hoverStrip(width: showFloatingSidebar ? floatingWidth : 10) + Spacer() + } else { + Spacer() + hoverStrip(width: showFloatingSidebar ? floatingWidth : 10) + } + } + .zIndex(2) + } + } + } + + @ViewBuilder + private func hoverStrip(width: CGFloat) -> some View { + Color.clear + .frame(width: width) + .overlay( + GlobalMouseTrackingArea( + mouseEntered: Binding( + get: { showFloatingSidebar }, + set: { newValue in + isMouseOverSidebar = newValue + if !newValue, isDownloadsPopoverOpen { + return + } + showFloatingSidebar = newValue + } + ), + edge: sidebarManager.sidebarPosition == .primary ? .left : .right, + padding: 40, + slack: 8 + ) + ) + } +} + +private struct ResizeHandle: View { + @Binding var dragFraction: CGFloat? + var sidebarFraction: FractionHolder + let sidebarPosition: SidebarPosition + let floatingWidth: CGFloat + let totalWidth: CGFloat + let minFraction: CGFloat + let maxFraction: CGFloat + + var body: some View { + Rectangle() + .fill(Color.clear) + .frame(width: 14) + #if targetEnvironment(macCatalyst) || os(macOS) + .cursor(NSCursor.resizeLeftRight) + #endif + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { value in + let proposedWidth: CGFloat = if sidebarPosition == .primary { + max(0, min(floatingWidth + value.translation.width, totalWidth)) + } else { + max(0, min(floatingWidth - value.translation.width, totalWidth)) + } + + let newFraction = proposedWidth / max(totalWidth, 1) + dragFraction = min(max(newFraction, minFraction), maxFraction) + } + .onEnded { _ in + if let fraction = dragFraction { + sidebarFraction.value = fraction + } + dragFraction = nil + } + ) + } +} diff --git a/ora/Modules/Find/FindView.swift b/ora/Modules/Find/FindView.swift index 22a8aefb..7ecc2799 100644 --- a/ora/Modules/Find/FindView.swift +++ b/ora/Modules/Find/FindView.swift @@ -14,6 +14,7 @@ struct FindView: View { @State private var currentMatch = 0 @FocusState private var isTextFieldFocused: Bool @EnvironmentObject private var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager @Environment(\.theme) var theme @Environment(\.colorScheme) var colorScheme private let controller: FindController diff --git a/ora/Modules/Importer/ImportDataButton.swift b/ora/Modules/Importer/ImportDataButton.swift index 5eaec0b6..f97fd10f 100644 --- a/ora/Modules/Importer/ImportDataButton.swift +++ b/ora/Modules/Importer/ImportDataButton.swift @@ -2,7 +2,7 @@ import SwiftUI struct ImportDataButton: View { @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var historyManger: HistoryManager + @EnvironmentObject var historyManager: HistoryManager @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var privacyMode: PrivacyMode @@ -36,7 +36,7 @@ struct ImportDataButton: View { title: tab.title, url: url, container: container, - historyManager: historyManger, + historyManager: historyManager, downloadManager: downloadManager, isPrivate: privacyMode.isPrivate ) @@ -73,7 +73,7 @@ struct ImportDataButton: View { title: tab.title, url: url, container: container, - historyManager: historyManger, + historyManager: historyManager, downloadManager: downloadManager, isPrivate: privacyMode.isPrivate ) diff --git a/ora/Modules/Launcher/LauncherView.swift b/ora/Modules/Launcher/LauncherView.swift index 4b622373..a574411f 100644 --- a/ora/Modules/Launcher/LauncherView.swift +++ b/ora/Modules/Launcher/LauncherView.swift @@ -3,13 +3,13 @@ import SwiftUI struct LauncherView: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager @EnvironmentObject var tabManager: TabManager @EnvironmentObject var historyManager: HistoryManager @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var privacyMode: PrivacyMode @Environment(\.theme) private var theme @StateObject private var searchEngineService = SearchEngineService() - @StateObject private var faviconService = FaviconService() @State private var input = "" @State private var isVisible = false @@ -25,7 +25,6 @@ struct LauncherView: View { .first { $0.searchURL == searchEngine.searchURL } match = searchEngine.toLauncherMatch( originalAlias: input, - faviconService: faviconService, customEngine: customEngine ) input = "" @@ -37,13 +36,14 @@ struct LauncherView: View { var engineToUse = match if engineToUse == nil, - let defaultEngine = searchEngineService.getDefaultSearchEngine(for: tabManager.activeContainer?.id) + let defaultEngine = searchEngineService.getDefaultSearchEngine( + for: tabManager.activeContainer?.id + ) { let customEngine = searchEngineService.settings.customSearchEngines .first { $0.searchURL == defaultEngine.searchURL } engineToUse = defaultEngine.toLauncherMatch( originalAlias: correctInput, - faviconService: faviconService, customEngine: customEngine ) } @@ -88,6 +88,7 @@ struct LauncherView: View { color: match?.faviconBackgroundColor ?? match?.color ?? .clear, trigger: match != nil ) + .padding(.horizontal, 20) // Add horizontal margins around the search bar .offset(y: 250) .scaleEffect(isVisible ? 1.0 : 0.9) .opacity(isVisible ? 1.0 : 0.0) @@ -101,9 +102,6 @@ struct LauncherView: View { .onChange(of: appState.showLauncher) { _, newValue in isVisible = newValue } - // .onChange(of: theme) { _, newValue in - // searchEngineService.setTheme(newValue) - // } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onExitCommand { diff --git a/ora/Modules/Launcher/Main/LauncherMain.swift b/ora/Modules/Launcher/Main/LauncherMain.swift index 244e083f..a468506b 100644 --- a/ora/Modules/Launcher/Main/LauncherMain.swift +++ b/ora/Modules/Launcher/Main/LauncherMain.swift @@ -48,15 +48,17 @@ struct LauncherMain: View { @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var tabManager: TabManager @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager @EnvironmentObject var privacyMode: PrivacyMode @State var focusedElement: UUID = .init() - @StateObject private var faviconService = FaviconService() + @StateObject private var searchEngineService = SearchEngineService() - @State private var suggestions: [LauncherSuggestion] = [ - ] + @State private var suggestions: [LauncherSuggestion] = [] - private func createAISuggestion(engineName: SearchEngineID, query: String? = nil) -> LauncherSuggestion { + private func createAISuggestion(engineName: SearchEngineID, query: String? = nil) + -> LauncherSuggestion + { guard let engine = searchEngineService.getSearchEngine(engineName) else { return LauncherSuggestion( type: .aiChat, @@ -66,8 +68,8 @@ struct LauncherMain: View { ) } - let favicon = faviconService.getFavicon(for: engine.searchURL) - let faviconURL = faviconService.faviconURL(for: URL(string: engine.searchURL)?.host ?? "") + _ = FaviconService.shared.getFavicon(for: engine.searchURL) + let faviconURL = FaviconService.shared.faviconURL(for: URL(string: engine.searchURL)?.host ?? "") return LauncherSuggestion( type: .aiChat, @@ -147,7 +149,7 @@ struct LauncherMain: View { action: { if !tab.isWebViewReady { tab.restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -163,13 +165,14 @@ struct LauncherMain: View { private func appendOpenURLSuggestionIfNeeded(_ text: String) { guard let candidateURL = URL(string: text) else { return } - let finalURL: URL? = if candidateURL.scheme != nil { - candidateURL - } else if isValidURL(text) { - constructURL(from: text) - } else { - nil - } + let finalURL: URL? = + if candidateURL.scheme != nil { + candidateURL + } else if isValidURL(text) { + constructURL(from: text) + } else { + nil + } guard let url = finalURL else { return } suggestions.append( LauncherSuggestion( @@ -318,7 +321,8 @@ struct LauncherMain: View { onMoveDown: { moveFocusedElement(.down) }, - cursorColor: match?.faviconBackgroundColor ?? match?.color ?? (theme.foreground), + cursorColor: match?.faviconBackgroundColor ?? match?.color + ?? (theme.foreground), placeholder: getPlaceholder(match: match) ) .onChange(of: text) { _, _ in @@ -349,7 +353,8 @@ struct LauncherMain: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .inset(by: 0.25) .stroke( - Color(match?.faviconBackgroundColor ?? match?.color ?? theme.foreground).opacity(0.15), + Color(match?.faviconBackgroundColor ?? match?.color ?? theme.foreground) + .opacity(0.15), lineWidth: 0.5 ) ) @@ -357,43 +362,21 @@ struct LauncherMain: View { color: Color.black.opacity(0.1), radius: 40, x: 0, y: 24 ) - .padding(.horizontal, 20) // Add horizontal margins around the entire search bar } private func getPlaceholder(match: Match?) -> String { if match == nil { return "Search the web or enter url..." } - switch match!.text { - case "X": - return "Search on X" - case "Youtube": - return "Search on Youtube" - case "Google": - return "Search on Google" - case "ChatGPT": - return "Ask ChatGPT" - case "Claude": - return "Ask Claude" - case "Grok": - return "Ask Grok" - case "Perplexity": - return "Ask Perplexity" - case "Reddit": - return "Search on Reddit" - case "T3Chat": - return "Ask T3Chat" - case "Gemini": - return "Ask Gemini" - case "Copilot": - return "Ask Copilot" - case "GitHub Copilot": - return "Ask GitHub Copilot" - case "Meta AI": - return "Ask Meta AI" - default: - return "Search on \(match!.text)" + + // Find the search engine by name to get its isAIChat property + if let engine = searchEngineService.getSearchEngine(byName: match!.text) { + let prefix = engine.isAIChat ? "Ask" : "Search on" + return "\(prefix) \(engine.name)" } + + // Fallback (should rarely happen) + return "Search on \(match!.text)" } private func getIconName(match: Match?, text: String) -> String { diff --git a/ora/Modules/Launcher/Suggestions/LauncherSuggestionItem.swift b/ora/Modules/Launcher/Suggestions/LauncherSuggestionItem.swift index 009ccaf0..992df419 100644 --- a/ora/Modules/Launcher/Suggestions/LauncherSuggestionItem.swift +++ b/ora/Modules/Launcher/Suggestions/LauncherSuggestionItem.swift @@ -44,6 +44,7 @@ struct LauncherSuggestionItem: View { @State private var isHovered = false @Environment(\.theme) private var theme @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager init(suggestion: LauncherSuggestion, defaultAI: SearchEngine?, focusedElement: Binding) { self.suggestion = suggestion diff --git a/ora/Modules/Player/GlobalMediaPlayer.swift b/ora/Modules/Player/GlobalMediaPlayer.swift index a7770d5e..068f58ba 100644 --- a/ora/Modules/Player/GlobalMediaPlayer.swift +++ b/ora/Modules/Player/GlobalMediaPlayer.swift @@ -47,11 +47,22 @@ private struct MediaPlayerCard: View { @State private var showVolume: Bool = false @State private var hovered: Bool = false - private var faviconImage: Image { + private var faviconView: some View { if let url = session.favicon { - return Image(nsImage: NSImage(byReferencing: url)) + return AnyView( + AsyncImage(url: url) { image in + image.resizable() + } placeholder: { + Image(systemName: "play.rectangle.fill") + .resizable() + } + ) + } else { + return AnyView( + Image(systemName: "play.rectangle.fill") + .resizable() + ) } - return Image(systemName: "play.rectangle.fill") } var body: some View { @@ -76,8 +87,7 @@ private struct MediaPlayerCard: View { HStack { Button { tabManager.activateTab(id: session.tabID) } label: { - faviconImage - .resizable() + faviconView .scaledToFit() .frame(width: 18, height: 18) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) diff --git a/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift new file mode 100644 index 00000000..84269b7a --- /dev/null +++ b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift @@ -0,0 +1,194 @@ +import SwiftUI + +@Observable +class ExtensionViewModel { + var directories: [URL] = [] + var isInstalled = false +} + +struct ExtensionsSettingsView: View { + @State private var viewModel = ExtensionViewModel() + @State private var isImporting = false + @State private var extensionsDir: URL? + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Manage Extensions") + .font(.title) + .fontWeight(.semibold) + .padding(.top, 20) + + if let dir = extensionsDir { + Text("Extensions Directory: \(dir.path)") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Button("Import Extension Zip") { + isImporting = true + Task { + await importAndExtractZip() + } + } + + Divider() + + Text("Installed Extensions:") + .font(.headline) + + ScrollView { + VStack(spacing: 10) { + ForEach(viewModel.directories, id: \.path) { dir in + HStack { + Text(dir.lastPathComponent) + Spacer() + Button("Install") { + Task { + await ExtensionManager.shared.installExtension(from: dir) + } + } + .buttonStyle(.bordered) + if let extensionToUninstall = ExtensionManager.shared.extensionMap[dir] { + Button("Delete") { + ExtensionManager.shared.uninstallExtension(extensionToUninstall) + // Remove the directory + try? FileManager.default.removeItem(at: dir) + // Reload directories + loadExtensionDirectories() + } + .buttonStyle(.bordered) + .foregroundColor(.red) + } + } + .padding(.horizontal) + } + } + } + .frame(height: 200) + } + .padding() + .onAppear { + setupExtensionsDirectory() + loadExtensionDirectories() + } + .onChange(of: viewModel.isInstalled) { _, _ in + loadExtensionDirectories() + } + } + + private func setupExtensionsDirectory() { + let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + extensionsDir = supportDir.appendingPathComponent("extensions") + if !FileManager.default.fileExists(atPath: extensionsDir!.path) { + try? FileManager.default.createDirectory(at: extensionsDir!, withIntermediateDirectories: true) + } + } + + private func loadExtensionDirectories() { + guard let dir = extensionsDir else { return } + do { + let contents = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.isDirectoryKey] + ) + viewModel.directories = contents.filter { url in + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + return isDir.boolValue + } + } catch { + print("Failed to load directories: \(error)") + } + } + + private func importAndExtractZip() async { + // Use NSOpenPanel to let the user select a ZIP file + let openPanel = NSOpenPanel() + openPanel.allowedContentTypes = [.zip] + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + + guard openPanel.runModal() == .OK, let zipURL = openPanel.urls.first else { + print("No file selected or user canceled.") + return + } + + guard let destDir = extensionsDir else { return } + + // Create a subfolder named after the zip file (without .zip) + let zipName = zipURL.deletingPathExtension().lastPathComponent + let extractDir = destDir.appendingPathComponent(zipName) + if !FileManager.default.fileExists(atPath: extractDir.path) { + try? FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) + } + + // Copy zip to temp location inside extractDir + let tempZipURL = extractDir.appendingPathComponent("temp.zip") + do { + try FileManager.default.copyItem(at: zipURL, to: tempZipURL) + } catch { + print("Failed to copy zip: \(error)") + return + } + + // Extract using Process + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", tempZipURL.path, "-d", extractDir.path] + + do { + try process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + print("Extraction successful") + + // Delete temp.zip + try? FileManager.default.removeItem(at: tempZipURL) + + // Flattens extension folder structure. + flattenDir(from: extractDir, to: zipName) + + // Remove __MACOSX (macOS metadata) if it exists + cleanUp(extractDir) + + // Reload as needed + loadExtensionDirectories() + } else { + print("Extraction failed") + } + } catch { + print("Failed to extract: \(error)") + } + } + + func flattenDir(from extractDir: URL, to zipName: String) { + // Move contents of extractDir/zipName to extractDir + let nestedDir = extractDir.appendingPathComponent(zipName) + if FileManager.default.fileExists(atPath: nestedDir.path) { + do { + let contents = try FileManager.default.contentsOfDirectory( + at: nestedDir, + includingPropertiesForKeys: nil + ) + for item in contents { + let destinationURL = extractDir.appendingPathComponent(item.lastPathComponent) + try? FileManager.default.moveItem(at: item, to: destinationURL) + } + + // Remove the nested folder after moving + try? FileManager.default.removeItem(at: nestedDir) + } catch { + print("Error moving nested contents: \(error)") + } + } + } + + func cleanUp(_ extractDir: URL) { + let macosxDir = extractDir.appendingPathComponent("__MACOSX") + if FileManager.default.fileExists(atPath: macosxDir.path) { + try? FileManager.default.removeItem(at: macosxDir) + } + } +} diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index f4863c5c..505e6a94 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -5,6 +5,7 @@ struct GeneralSettingsView: View { @EnvironmentObject var appearanceManager: AppearanceManager @EnvironmentObject var updateService: UpdateService @StateObject private var settings = SettingsStore.shared + @StateObject private var defaultBrowserManager = DefaultBrowserManager.shared @Environment(\.theme) var theme var body: some View { @@ -30,18 +31,75 @@ struct GeneralSettingsView: View { .background(theme.solidWindowBackgroundColor) .cornerRadius(8) - HStack { - Text("Born for your Mac. Make Ora your default browser.") - Spacer() - Button("Set Ora as default") { openDefaultBrowserSettings() } + if !defaultBrowserManager.isDefault { + HStack { + Text("Born for your Mac. Make Ora your default browser.") + Spacer() + Button("Set Ora as default") { DefaultBrowserManager.requestSetAsDefault() } + } + .frame(maxWidth: .infinity) + .padding(8) + .background(theme.solidWindowBackgroundColor) + .cornerRadius(8) } - .frame(maxWidth: .infinity) - .padding(8) - .background(theme.solidWindowBackgroundColor) - .cornerRadius(8) AppearanceSelector(selection: $appearanceManager.appearance) + VStack(alignment: .leading, spacing: 12) { + Text("Tab Management") + .font(.headline) + + VStack(alignment: .leading, spacing: 8) { + Text("Automatically clean up old tabs to preserve memory.") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + Text("Destroy web views after:") + Spacer() + Picker("", selection: $settings.tabAliveTimeout) { + Text("1 hour").tag(TimeInterval(60 * 60)) + Text("6 hours").tag(TimeInterval(6 * 60 * 60)) + Text("12 hours").tag(TimeInterval(12 * 60 * 60)) + Text("1 day").tag(TimeInterval(24 * 60 * 60)) + Text("2 days").tag(TimeInterval(2 * 24 * 60 * 60)) + Text("Never").tag(TimeInterval(365 * 24 * 60 * 60)) // Effectively never + } + .frame(width: 120) + } + + HStack { + Text("Remove tabs completely after:") + Spacer() + Picker("", selection: $settings.tabRemovalTimeout) { + Text("1 hour").tag(TimeInterval(60 * 60)) + Text("6 hours").tag(TimeInterval(6 * 60 * 60)) + Text("12 hours").tag(TimeInterval(12 * 60 * 60)) + Text("1 day").tag(TimeInterval(24 * 60 * 60)) + Text("2 days").tag(TimeInterval(2 * 24 * 60 * 60)) + Text("Never").tag(TimeInterval(365 * 24 * 60 * 60)) // Effectively never + } + .frame(width: 120) + } + + HStack { + Text("Maximum recent tabs to keep in view:") + Spacer() + Picker("", selection: $settings.maxRecentTabs) { + ForEach(1 ... 10, id: \.self) { num in + Text("\(num)").tag(num) + } + } + .frame(width: 80) + } + Text("Note: Pinned and favorite tabs are never automatically removed.") + .font(.caption2) + .foregroundColor(.secondary) + } + + Toggle("Auto Picture-in-Picture on tab switch", isOn: $settings.autoPiPEnabled) + } + .padding(.vertical, 8) VStack(alignment: .leading, spacing: 12) { Text("Updates") .font(.headline) @@ -88,21 +146,11 @@ struct GeneralSettingsView: View { } } } - .padding(.vertical, 8) } } } } - private func openDefaultBrowserSettings() { - guard - let url = URL( - string: "x-apple.systempreferences:com.apple.preference.general?DefaultWebBrowser" - ) - else { return } - NSWorkspace.shared.open(url) - } - private func getAppVersion() -> String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" diff --git a/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift b/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift index c475abd1..da2c27d8 100644 --- a/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift +++ b/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift @@ -4,17 +4,19 @@ import SwiftUI struct SearchEngineSettingsView: View { @StateObject private var settings = SettingsStore.shared @StateObject private var searchEngineService = SearchEngineService() - @StateObject private var faviconService = FaviconService() + @Environment(\.theme) var theme @State private var showingAddForm = false @State private var newEngineName = "" @State private var newEngineURL = "" @State private var newEngineAliases = "" + @State private var newEngineIsAI = false private var isValidURL: Bool { newEngineURL - .contains("{query}") && URL(string: newEngineURL.replacingOccurrences(of: "{query}", with: "test")) != nil + .contains("{query}") + && URL(string: newEngineURL.replacingOccurrences(of: "{query}", with: "test")) != nil } var body: some View { @@ -63,7 +65,10 @@ struct SearchEngineSettingsView: View { Text("URL:") .frame(width: 80, alignment: .leading) VStack(alignment: .leading, spacing: 4) { - TextField("https://example.com/search?q={query}", text: $newEngineURL) + TextField( + "https://example.com/search?q={query}", + text: $newEngineURL + ) Text("Include {query} where the search term should go") .font(.caption) .foregroundColor(.secondary) @@ -86,6 +91,19 @@ struct SearchEngineSettingsView: View { } } + HStack { + Text("Type:") + .frame(width: 80, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + Toggle("AI Chat Engine", isOn: $newEngineIsAI) + Text( + "Check if this is an AI chat service (affects placeholder text)" + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + HStack { Spacer() Button("Save") { @@ -115,20 +133,54 @@ struct SearchEngineSettingsView: View { .font(.caption) .foregroundStyle(.secondary) - // Built-in search engines - ForEach(searchEngineService.builtInSearchEngines, id: \.name) { engine in - BuiltInSearchEngineRow( - engine: engine, - isDefault: settings.globalDefaultSearchEngine == engine - .name || (settings.globalDefaultSearchEngine == nil && engine.name == "Google"), - onSetAsDefault: { - if engine.name == "Google" { - settings.globalDefaultSearchEngine = nil - } else { + // Conventional Search Engines + let conventionalEngines = searchEngineService.builtInSearchEngines.filter { + !$0.isAIChat + } + if !conventionalEngines.isEmpty { + Text("Conventional Search Engines") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 4) + + ForEach(conventionalEngines, id: \.name) { engine in + BuiltInSearchEngineRow( + engine: engine, + isDefault: settings.globalDefaultSearchEngine + == engine + .name + || (settings.globalDefaultSearchEngine == nil + && engine.name == "Google" + ), + onSetAsDefault: { + if engine.name == "Google" { + settings.globalDefaultSearchEngine = nil + } else { + settings.globalDefaultSearchEngine = engine.name + } + } + ) + } + } + + // AI Search Engines + let aiEngines = searchEngineService.builtInSearchEngines.filter(\.isAIChat) + if !aiEngines.isEmpty { + Text("AI Search Engines") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 8) + .padding(.bottom, 4) + + ForEach(aiEngines, id: \.name) { engine in + BuiltInSearchEngineRow( + engine: engine, + isDefault: settings.globalDefaultSearchEngine == engine.name, + onSetAsDefault: { settings.globalDefaultSearchEngine = engine.name } - } - ) + ) + } } if !settings.customSearchEngines.isEmpty { @@ -173,6 +225,7 @@ struct SearchEngineSettingsView: View { newEngineName = "" newEngineURL = "" newEngineAliases = "" + newEngineIsAI = false } private func cancelForm() { @@ -188,16 +241,18 @@ struct SearchEngineSettingsView: View { } private func saveSearchEngine() { - let aliasesList = newEngineAliases - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + let aliasesList = + newEngineAliases + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } // Create engine with favicon fetched upfront CustomSearchEngine.createWithFavicon( name: newEngineName, searchURL: newEngineURL, - aliases: aliasesList + aliases: aliasesList, + isAIChat: newEngineIsAI ) { [weak settings] engine in settings?.addCustomSearchEngine(engine) } @@ -238,16 +293,6 @@ struct BuiltInSearchEngineRow: View { Text(engine.name) .font(.body) - if engine.isAIChat { - Text("AI") - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.purple.opacity(0.2)) - .foregroundColor(.purple) - .cornerRadius(4) - } - if isDefault { Text("Default") .font(.caption) @@ -284,9 +329,11 @@ struct CustomSearchEngineRow: View { @State private var editName = "" @State private var editURL = "" @State private var editAliases = "" + @State private var editIsAI = false private var isValidEditURL: Bool { - editURL.contains("{query}") && URL(string: editURL.replacingOccurrences(of: "{query}", with: "test")) != nil + editURL.contains("{query}") + && URL(string: editURL.replacingOccurrences(of: "{query}", with: "test")) != nil } var body: some View { @@ -341,6 +388,19 @@ struct CustomSearchEngineRow: View { TextField("e.g., ddg, duck", text: $editAliases) } + HStack { + Text("Type:") + .frame(width: 80, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + Toggle("AI Chat Engine", isOn: $editIsAI) + Text( + "Check if this is an AI chat service (affects placeholder text)" + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + HStack { Spacer() Button("Cancel") { @@ -372,7 +432,7 @@ struct CustomSearchEngineRow: View { } } - // Name and Default badge + // Name and badges HStack(spacing: 8) { Text(engine.name) .font(.body) @@ -385,6 +445,15 @@ struct CustomSearchEngineRow: View { .foregroundColor(.blue) .cornerRadius(4) } + if engine.isAIChat { + Text("AI") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.purple.opacity(0.2)) + .foregroundColor(.purple) + .cornerRadius(4) + } } Spacer() @@ -429,13 +498,15 @@ struct CustomSearchEngineRow: View { editName = engine.name editURL = engine.searchURL editAliases = engine.aliases.joined(separator: ", ") + editIsAI = engine.isAIChat } private func saveEdit() { - let aliasesList = editAliases - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + let aliasesList = + editAliases + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } // Create updated engine with favicon if URL changed, otherwise keep existing favicon if editURL != engine.searchURL { @@ -444,7 +515,8 @@ struct CustomSearchEngineRow: View { id: engine.id, name: editName, searchURL: editURL, - aliases: aliasesList + aliases: aliasesList, + isAIChat: editIsAI ) { [weak settings] updatedEngine in settings?.updateCustomSearchEngine(updatedEngine) } @@ -456,7 +528,8 @@ struct CustomSearchEngineRow: View { searchURL: editURL, aliases: aliasesList, faviconData: engine.faviconData, - faviconBackgroundColorData: engine.faviconBackgroundColorData + faviconBackgroundColorData: engine.faviconBackgroundColorData, + isAIChat: editIsAI ) settings.updateCustomSearchEngine(updatedEngine) } diff --git a/ora/Modules/Settings/Sections/SpacesSettingsView.swift b/ora/Modules/Settings/Sections/SpacesSettingsView.swift index 570eb044..95cfc12b 100644 --- a/ora/Modules/Settings/Sections/SpacesSettingsView.swift +++ b/ora/Modules/Settings/Sections/SpacesSettingsView.swift @@ -7,14 +7,14 @@ struct SpacesSettingsView: View { @StateObject private var settings = SettingsStore.shared @State private var searchService = SearchEngineService() @State private var selectedContainerId: UUID? - @EnvironmentObject var historyManger: HistoryManager + @EnvironmentObject var historyManager: HistoryManager private var selectedContainer: TabContainer? { containers.first { $0.id == selectedContainerId } ?? containers.first } var body: some View { - SettingsContainer(maxContentWidth: 1040) { + SettingsContainer(maxContentWidth: 1040, usesScrollView: false) { HStack(spacing: 0) { // Left list List(selection: $selectedContainerId) { @@ -31,88 +31,153 @@ struct SpacesSettingsView: View { Divider() // Right details - VStack(alignment: .leading, spacing: 20) { - if let container = selectedContainer { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Space-Specific Defaults") - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(.secondary) - } + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if let container = selectedContainer { + // Space-Specific Defaults Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Space-Specific Defaults") + .font(.headline) + .fontWeight(.semibold) + Text("Configure default settings for this space") + .font(.caption) + .foregroundStyle(.secondary) + } - VStack(alignment: .leading, spacing: 8) { - Text("Search Engine Override") - .font(.caption) - .foregroundStyle(.secondary) - Picker( - "Search engine", - selection: Binding( - get: { - settings.defaultSearchEngineId(for: container.id) - }, - set: { settings.setDefaultSearchEngineId($0, for: container.id) } - ) - ) { - Text("Use Global Default").tag(nil as String?) - Divider() - ForEach(searchService.searchEngines.filter { !$0.isAIChat }, id: \.name) { engine in - Text(engine.name).tag(Optional(engine.name)) + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text("Search Engine Override") + .font(.subheadline) + .fontWeight(.medium) + Picker( + "Search engine", + selection: Binding( + get: { + settings.defaultSearchEngineId(for: container.id) + }, + set: { settings.setDefaultSearchEngineId($0, for: container.id) } + ) + ) { + Text("Use Global Default").tag(nil as String?) + Divider() + ForEach( + searchService.searchEngines.filter { !$0.isAIChat }, + id: \.name + ) { engine in + Text(engine.name).tag(Optional(engine.name)) + } + } + .pickerStyle(.menu) + .frame(minWidth: 200, maxWidth: .infinity, alignment: .leading) } - } - } + .padding(12) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) - VStack(alignment: .leading, spacing: 8) { - Text("AI Chat Override") - .font(.caption) - .foregroundStyle(.secondary) - Picker( - "AI Chat", - selection: Binding( - get: { - settings.defaultAIEngineId(for: container.id) - }, - set: { settings.setDefaultAIEngineId($0, for: container.id) } - ) - ) { - Text("Use Global Default").tag(nil as String?) - Divider() - ForEach(searchService.searchEngines.filter(\.isAIChat), id: \.name) { engine in - Text(engine.name).tag(Optional(engine.name)) + VStack(alignment: .leading, spacing: 8) { + Text("AI Chat Override") + .font(.subheadline) + .fontWeight(.medium) + Picker( + "AI Chat", + selection: Binding( + get: { + settings.defaultAIEngineId(for: container.id) + }, + set: { settings.setDefaultAIEngineId($0, for: container.id) } + ) + ) { + Text("Use Global Default").tag(nil as String?) + Divider() + ForEach( + searchService.searchEngines.filter(\.isAIChat), + id: \.name + ) { engine in + Text(engine.name).tag(Optional(engine.name)) + } + } + .pickerStyle(.menu) + .frame(minWidth: 200, maxWidth: .infinity, alignment: .leading) } + .padding(12) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) + + VStack(alignment: .leading, spacing: 8) { + Text("Auto Clear Tabs") + .font(.subheadline) + .fontWeight(.medium) + Picker( + "Clear tabs after", + selection: Binding( + get: { settings.autoClearTabsAfter(for: container.id) }, + set: { settings.setAutoClearTabsAfter($0, for: container.id) } + ) + ) { + ForEach(AutoClearTabsAfter.allCases) { value in + Text(value.rawValue).tag(value) + } + } + .pickerStyle(.menu) + .frame(minWidth: 200, maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) } } - Picker( - "Clear tabs after", - selection: Binding( - get: { settings.autoClearTabsAfter(for: container.id) }, - set: { settings.setAutoClearTabsAfter($0, for: container.id) } - ) - ) { - ForEach(AutoClearTabsAfter.allCases) { value in - Text(value.rawValue).tag(value) + Divider() + + // Clear Data Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Privacy & Data") + .font(.headline) + .fontWeight(.semibold) + Text("Clear stored data for this space") + .font(.caption) + .foregroundStyle(.secondary) } + + VStack(spacing: 8) { + Button("Clear Cache") { + PrivacyService.clearCache(container) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button("Clear Cookies") { + PrivacyService.clearCookies(container) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button("Clear History") { + historyManager.clearContainerHistory(container) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) } - } - .padding(8) - VStack(alignment: .leading, spacing: 12) { - Text("Clear Data").foregroundStyle(.secondary) - Button("Clear Cache") { PrivacyService.clearCache(container) } - Button("Clear Cookies") { PrivacyService.clearCookies(container) } - Button("Clear Browsing History") { - historyManger.clearContainerHistory(container) + } else { + VStack(spacing: 12) { + Text("No spaces found") + .font(.headline) + .foregroundStyle(.secondary) + Text("Create a space to configure its settings") + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .padding(8) - - } else { - Text("No spaces found").foregroundStyle(.secondary) + Spacer(minLength: 0) } - Spacer(minLength: 0) + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) } - .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() } } .onAppear { if selectedContainerId == nil { selectedContainerId = containers.first?.id } } diff --git a/ora/Modules/Settings/SettingsContentView.swift b/ora/Modules/Settings/SettingsContentView.swift index 2ec2801f..0b105c08 100644 --- a/ora/Modules/Settings/SettingsContentView.swift +++ b/ora/Modules/Settings/SettingsContentView.swift @@ -1,7 +1,7 @@ import SwiftUI enum SettingsTab: Hashable { - case general, spaces, privacySecurity, shortcuts, searchEngines + case general, spaces, privacySecurity, shortcuts, searchEngines, extensions var title: String { switch self { @@ -10,6 +10,7 @@ enum SettingsTab: Hashable { case .privacySecurity: return "Privacy" case .shortcuts: return "Shortcuts" case .searchEngines: return "Search" + case .extensions: return "Extensions" } } @@ -20,6 +21,7 @@ enum SettingsTab: Hashable { case .privacySecurity: return "lock.shield" case .shortcuts: return "command" case .searchEngines: return "magnifyingglass" + case .extensions: return "puzzlepiece" } } } @@ -50,6 +52,10 @@ struct SettingsContentView: View { SearchEngineSettingsView() .tabItem { Label(SettingsTab.searchEngines.title, systemImage: SettingsTab.searchEngines.symbol) } .tag(SettingsTab.searchEngines) + + ExtensionsSettingsView() + .tabItem { Label(SettingsTab.extensions.title, systemImage: SettingsTab.extensions.symbol) } + .tag(SettingsTab.extensions) } .tabViewStyle(.automatic) .frame(width: 600, height: 350) diff --git a/ora/Modules/Sidebar/BottomOption/ContainerForm.swift b/ora/Modules/Sidebar/BottomOption/ContainerForm.swift new file mode 100644 index 00000000..b7a35f9f --- /dev/null +++ b/ora/Modules/Sidebar/BottomOption/ContainerForm.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct ContainerForm: View { + @Binding var name: String + @Binding var emoji: String + @Binding var isEmojiPickerOpen: Bool + @FocusState.Binding var isTextFieldFocused: Bool + + let onSubmit: () -> Void + let defaultEmoji: String + + @Environment(\.theme) private var theme + @State private var isEmojiPickerHovering = false + + var body: some View { + HStack(spacing: 8) { + emojiPickerButton + nameTextField + } + } + + private var emojiPickerButton: some View { + Button(action: { + isEmojiPickerOpen.toggle() + }) { + ZStack { + RoundedRectangle(cornerRadius: ContainerConstants.UI.cornerRadius, style: .continuous) + .stroke( + emoji.isEmpty ? theme.border : theme.border, + style: emoji.isEmpty + ? StrokeStyle(lineWidth: 1, dash: [5]) + : StrokeStyle(lineWidth: 1) + ) + .animation( + .easeOut(duration: ContainerConstants.Animation.emojiPickerDuration), + value: emoji.isEmpty + ) + .background(isEmojiPickerHovering ? Color.gray.opacity(0.3) : Color.gray.opacity(0.2)) + .cornerRadius(ContainerConstants.UI.cornerRadius) + + if emoji.isEmpty { + Image(systemName: "plus") + .font(.system(size: 12)) + } else { + Text(emoji) + .font(.system(size: 12)) + } + } + } + .popover(isPresented: $isEmojiPickerOpen, arrowEdge: .bottom) { + EmojiPickerView(onSelect: { selectedEmoji in + emoji = selectedEmoji + isEmojiPickerOpen = false + }) + } + .frame(width: ContainerConstants.UI.emojiButtonSize, height: ContainerConstants.UI.emojiButtonSize) + .background(isEmojiPickerHovering ? Color.gray.opacity(0.3) : Color.gray.opacity(0.2)) + .cornerRadius(ContainerConstants.UI.cornerRadius) + .buttonStyle(.plain) + .onHover { isEmojiPickerHovering = $0 } + } + + private var nameTextField: some View { + TextField("Name", text: $name) + .textFieldStyle(.plain) + .frame(maxWidth: .infinity) + .padding(8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(ContainerConstants.UI.cornerRadius) + .focused($isTextFieldFocused) + .onSubmit(onSubmit) + .overlay( + RoundedRectangle(cornerRadius: ContainerConstants.UI.cornerRadius, style: .continuous) + .stroke( + isTextFieldFocused ? theme.foreground.opacity(0.5) : theme.border, + lineWidth: isTextFieldFocused ? 2 : 1 + ) + ) + } +} diff --git a/ora/Modules/Sidebar/BottomOption/ContainerSwitcher.swift b/ora/Modules/Sidebar/BottomOption/ContainerSwitcher.swift index 24020d95..14e0de36 100644 --- a/ora/Modules/Sidebar/BottomOption/ContainerSwitcher.swift +++ b/ora/Modules/Sidebar/BottomOption/ContainerSwitcher.swift @@ -9,18 +9,18 @@ struct ContainerSwitcher: View { @Query var containers: [TabContainer] @State private var hoveredContainer: UUID? - - private let normalButtonWidth: CGFloat = 28 - let defaultEmoji = "•" - // Never used - private let compactButtonWidth: CGFloat = 12 + @State private var editingContainer: TabContainer? + @State private var isEditModalOpen = false var body: some View { GeometryReader { geometry in let availableWidth = geometry.size.width let totalWidth = - CGFloat(containers.count) * normalButtonWidth + CGFloat(max(0, containers.count - 1)) - * 2 + CGFloat(containers.count) * ContainerConstants.UI.normalButtonWidth + CGFloat(max( + 0, + containers.count - 1 + )) + * 2 let isCompact = totalWidth > availableWidth HStack(alignment: .center, spacing: isCompact ? 4 : 2) { @@ -33,6 +33,14 @@ struct ContainerSwitcher: View { } .padding(0) .frame(height: 28) + .popover(isPresented: $isEditModalOpen) { + if let container = editingContainer { + EditContainerModal( + container: container, + isPresented: $isEditModalOpen + ) + } + } } @ViewBuilder @@ -41,12 +49,15 @@ struct ContainerSwitcher: View { { let isActive = tabManager.activeContainer?.id == container.id let isHovered = hoveredContainer == container.id - let displayEmoji = isCompact && !isActive ? (isHovered ? container.emoji : defaultEmoji) : container.emoji - let buttonSize = isCompact && !isActive ? (isHovered ? compactButtonWidth + 4 : compactButtonWidth) : - normalButtonWidth - let fontSize: CGFloat = isCompact && !isActive ? (isHovered ? (container.emoji == defaultEmoji ? 24 : 12) : 12 - ) : - (container.emoji == defaultEmoji ? 24 : 12) + let displayEmoji = isCompact && !isActive ? (isHovered ? container.emoji : ContainerConstants.defaultEmoji) : + container.emoji + let buttonSize = isCompact && !isActive ? + (isHovered ? ContainerConstants.UI.compactButtonWidth + 4 : ContainerConstants.UI.compactButtonWidth) : + ContainerConstants.UI.normalButtonWidth + let fontSize: CGFloat = isCompact && !isActive ? + (isHovered ? (container.emoji == ContainerConstants.defaultEmoji ? 24 : 12) : 12 + ) : + (container.emoji == ContainerConstants.defaultEmoji ? 24 : 12) Button(action: { onContainerSelected(container) @@ -54,7 +65,7 @@ struct ContainerSwitcher: View { HStack { Text(displayEmoji) .font(.system(size: fontSize)) - .foregroundColor(displayEmoji == defaultEmoji ? .primary : .secondary) + .foregroundColor(displayEmoji == ContainerConstants.defaultEmoji ? .primary : .secondary) } .frame(width: buttonSize, height: buttonSize) .grayscale(!isActive && !isHovered ? 0.5 : 0) @@ -76,9 +87,10 @@ struct ContainerSwitcher: View { } } .contextMenu { - // Button("Rename Container") { - // tabManager.renameContainer(container, name: "New Name", emoji: "💩") - // } + Button("Edit Container") { + editingContainer = container + isEditModalOpen = true + } Button("Delete Container") { tabManager.deleteContainer(container) } diff --git a/ora/Modules/Sidebar/BottomOption/EditContainerModal.swift b/ora/Modules/Sidebar/BottomOption/EditContainerModal.swift new file mode 100644 index 00000000..130b3095 --- /dev/null +++ b/ora/Modules/Sidebar/BottomOption/EditContainerModal.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct EditContainerModal: View { + let container: TabContainer + @Binding var isPresented: Bool + + @Environment(\.theme) private var theme + @EnvironmentObject var tabManager: TabManager + + @State private var name: String = "" + @State private var emoji: String = "" + @State private var isEmojiPickerOpen = false + @FocusState private var isTextFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + headerView + containerForm + actionButtons + } + .frame(width: ContainerConstants.UI.popoverWidth) + .padding() + .onAppear { + setupInitialValues() + } + } + + private var headerView: some View { + Text("Edit Container") + .font(.headline) + } + + private var containerForm: some View { + ContainerForm( + name: $name, + emoji: $emoji, + isEmojiPickerOpen: $isEmojiPickerOpen, + isTextFieldFocused: $isTextFieldFocused, + onSubmit: saveContainer, + defaultEmoji: ContainerConstants.defaultEmoji + ) + } + + private var actionButtons: some View { + Button("Save") { + saveContainer() + } + .disabled(name.isEmpty) + } + + private func setupInitialValues() { + name = container.name + emoji = container.emoji + } + + private func saveContainer() { + guard !name.isEmpty else { return } + + let finalEmoji = emoji.isEmpty ? ContainerConstants.defaultEmoji : emoji + tabManager.renameContainer(container, name: name, emoji: finalEmoji) + isPresented = false + } +} diff --git a/ora/Modules/Sidebar/BottomOption/NewContainerButton.swift b/ora/Modules/Sidebar/BottomOption/NewContainerButton.swift index 0331395d..29981682 100644 --- a/ora/Modules/Sidebar/BottomOption/NewContainerButton.swift +++ b/ora/Modules/Sidebar/BottomOption/NewContainerButton.swift @@ -3,11 +3,9 @@ import SwiftUI struct NewContainerButton: View { @State private var isHovering = false - @State private var isEmojiPickerHovering = false @State private var isPopoverOpen = false @State private var name = "" @State private var emoji = "" - let defaultEmoji = "•" @State private var isEmojiPickerOpen = false @FocusState private var isTextFieldFocused: Bool @@ -34,76 +32,36 @@ struct NewContainerButton: View { Text("New Container") .font(.headline) - HStack(spacing: 8) { - Button(action: { - isEmojiPickerOpen.toggle() - }) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke( - emoji.isEmpty ? theme.border : theme.border, - style: emoji.isEmpty - ? StrokeStyle(lineWidth: 1, dash: [5]) - : StrokeStyle(lineWidth: 1) - ) - .animation(.easeOut(duration: 0.1), value: emoji.isEmpty) - .background(isEmojiPickerHovering ? Color.gray.opacity(0.3) : Color.gray.opacity(0.2)) - .cornerRadius(10) - if emoji.isEmpty { - Image(systemName: "plus") - .font(.system(size: 12)) - } else { - Text(emoji) - .font(.system(size: 12)) - } - } - } - .popover(isPresented: $isEmojiPickerOpen, arrowEdge: .bottom) { - EmojiPickerView(onSelect: { emoji in - self.emoji = emoji - isEmojiPickerOpen = false - }) - } - .frame(width: 32, height: 32) - .background(isEmojiPickerHovering ? Color.gray.opacity(0.3) : Color.gray.opacity(0.2)) - .cornerRadius(10) - .buttonStyle(.plain) - .onHover { isEmojiPickerHovering = $0 } - - TextField("Name", text: $name) - .textFieldStyle(.plain) - .frame(maxWidth: .infinity) - .padding(8) - .background(Color.gray.opacity(0.1)) - .cornerRadius(10) - .focused($isTextFieldFocused) - .onSubmit { - if !name.isEmpty { - tabManager.createContainer(name: name, emoji: emoji.isEmpty ? defaultEmoji : emoji) - isPopoverOpen = false - name = "" - emoji = "" - } - } - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke( - isTextFieldFocused ? theme.foreground.opacity(0.5) : theme.border, - lineWidth: isTextFieldFocused ? 2 : 1 - ) - ) - } + ContainerForm( + name: $name, + emoji: $emoji, + isEmojiPickerOpen: $isEmojiPickerOpen, + isTextFieldFocused: $isTextFieldFocused, + onSubmit: createContainer, + defaultEmoji: ContainerConstants.defaultEmoji + ) Button("Create") { - tabManager.createContainer(name: name, emoji: emoji.isEmpty ? defaultEmoji : emoji) - isPopoverOpen = false - name = "" - emoji = "" + createContainer() } .disabled(name.isEmpty) } - .frame(width: 300) + .frame(width: ContainerConstants.UI.popoverWidth) .padding() } } + + private func createContainer() { + guard !name.isEmpty else { return } + + let finalEmoji = emoji.isEmpty ? ContainerConstants.defaultEmoji : emoji + tabManager.createContainer(name: name, emoji: finalEmoji) + isPopoverOpen = false + resetForm() + } + + private func resetForm() { + name = "" + emoji = "" + } } diff --git a/ora/Modules/Sidebar/ContainerView.swift b/ora/Modules/Sidebar/ContainerView.swift index f7c6d696..a614137d 100644 --- a/ora/Modules/Sidebar/ContainerView.swift +++ b/ora/Modules/Sidebar/ContainerView.swift @@ -6,14 +6,17 @@ struct ContainerView: View { let containers: [TabContainer] @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager @EnvironmentObject var tabManager: TabManager @EnvironmentObject var privacyMode: PrivacyMode + + @State var isDragging = false @State private var draggedItem: UUID? @State private var editingURLString: String = "" var body: some View { VStack(alignment: .leading, spacing: 16) { - if appState.isToolbarHidden, let tab = tabManager.activeTab { + if toolbarManager.isToolbarHidden, let tab = tabManager.activeTab { SidebarURLDisplay( tab: tab, editingURLString: $editingURLString @@ -32,6 +35,7 @@ struct ContainerView: View { onSelect: selectTab, onFavoriteToggle: toggleFavorite, onClose: removeTab, + onDuplicate: duplicateTab, onMoveToContainer: moveTab ) } else { @@ -65,6 +69,7 @@ struct ContainerView: View { onPinToggle: togglePin, onFavoriteToggle: toggleFavorite, onClose: removeTab, + onDuplicate: duplicateTab, onMoveToContainer: moveTab, containers: containers ) @@ -78,13 +83,14 @@ struct ContainerView: View { onPinToggle: togglePin, onFavoriteToggle: toggleFavorite, onClose: removeTab, + onDuplicate: duplicateTab, onMoveToContainer: moveTab, onAddNewTab: addNewTab ) } } } - .modifier(OraWindowDragGesture()) + .modifier(OraWindowDragGesture(isDragging: $isDragging)) } private var favoriteTabs: [Tab] { @@ -137,6 +143,7 @@ struct ContainerView: View { } private func dragTab(_ tabId: UUID) -> NSItemProvider { + isDragging = true draggedItem = tabId let provider = TabItemProvider(object: tabId.uuidString as NSString) provider.didEnd = { @@ -146,33 +153,57 @@ struct ContainerView: View { } private func dropTab(_ tabId: String) { + isDragging = false draggedItem = nil } + + private func duplicateTab(_ tab: Tab) { + tabManager.duplicateTab(tab) + } } private struct OraWindowDragGesture: ViewModifier { + @Binding var isDragging: Bool + func body(content: Content) -> some View { - if #available(macOS 15.0, *) { - content.gesture(WindowDragGesture()) - } else { - content.gesture(BackportWindowDragGesture()) + Group { + if isDragging { + content + } else { + if #available(macOS 15.0, *) { + content.gesture(WindowDragGesture()) + } else { + content.gesture(BackportWindowDragGesture(isDragging: $isDragging)) + } + } } } } private struct BackportWindowDragGesture: Gesture { + @Binding var isDragging: Bool + struct Value: Equatable { static func == (lhs: Value, rhs: Value) -> Bool { true } } - init() {} + init(isDragging: Binding) { + self._isDragging = isDragging + } var body: some Gesture { DragGesture() .onChanged { _ in - if let nsWindow = NSApp.keyWindow, let event = NSApp.currentEvent { - nsWindow.performDrag(with: event) + /// Makes intent cleaner, if we're dragging, then just return + /// Maybe some other case needs to be watched for here + guard !isDragging else { + return } + guard let win = NSApp.keyWindow, let event = NSApp.currentEvent else { + return + } + + win.performDrag(with: event) } .map { _ in Value() } } diff --git a/ora/Modules/Sidebar/FloatingSidebar.swift b/ora/Modules/Sidebar/FloatingSidebar.swift index 698d51bc..8958543e 100644 --- a/ora/Modules/Sidebar/FloatingSidebar.swift +++ b/ora/Modules/Sidebar/FloatingSidebar.swift @@ -1,11 +1,11 @@ import SwiftUI struct FloatingSidebar: View { - let isFullscreen: Bool @Environment(\.theme) var theme + let sidebarCornerRadius: CGFloat = { if #available(macOS 26, *) { - return 8 + return 13 } else { return 6 } @@ -15,13 +15,12 @@ struct FloatingSidebar: View { let clipShape = ConditionallyConcentricRectangle(cornerRadius: sidebarCornerRadius) ZStack(alignment: .leading) { - SidebarView(isFullscreen: isFullscreen) + SidebarView() .background(theme.subtleWindowBackgroundColor) .background(BlurEffectView(material: .popover, blendingMode: .withinWindow)) .clipShape(clipShape) - .overlay( - clipShape - .stroke(theme.invertedSolidWindowBackgroundColor.opacity(0.3), lineWidth: 1) + .overlay(clipShape + .stroke(theme.invertedSolidWindowBackgroundColor.opacity(0.3), lineWidth: 1) ) } .padding(6) diff --git a/ora/Modules/Sidebar/SidebarToolbar.swift b/ora/Modules/Sidebar/SidebarToolbar.swift new file mode 100644 index 00000000..774b3996 --- /dev/null +++ b/ora/Modules/Sidebar/SidebarToolbar.swift @@ -0,0 +1,96 @@ +// +// SidebarToolbar.swift +// ora +// +// Created by Yonathan Dejene on 13/10/2025. +// +import SwiftUI + +struct SidebarToolbar: View { + @Environment(\.theme) private var theme + @EnvironmentObject var appState: AppState + @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var sidebarManager: SidebarManager + @EnvironmentObject var toolbarManager: ToolbarManager + + private var sidebarIcon: String { + sidebarManager.sidebarPosition == .secondary ? "sidebar.right" : "sidebar.left" + } + + var body: some View { + HStack(spacing: 0) { + if sidebarManager.sidebarPosition != .secondary { + WindowControls(isFullscreen: appState.isFullscreen).frame(height: 30) + } + + if toolbarManager.isToolbarHidden { + HStack(spacing: 0) { + if sidebarManager.sidebarPosition == .primary { + HStack { + URLBarButton( + systemName: sidebarIcon, + isEnabled: tabManager.activeTab != nil, + foregroundColor: theme.foreground.opacity(0.7), + action: { sidebarManager.toggleSidebar() } + ) + .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + Spacer() + } + } + URLBarButton( + systemName: "chevron.left", + isEnabled: tabManager.activeTab?.webView.canGoBack ?? false, + foregroundColor: theme.foreground.opacity(0.7), + action: { + if let activeTab = tabManager.activeTab { + activeTab.goBack() + } + } + ) + .oraShortcutHelp("Go Back", for: KeyboardShortcuts.Navigation.back) + + URLBarButton( + systemName: "chevron.right", + isEnabled: tabManager.activeTab?.webView.canGoForward ?? false, + foregroundColor: theme.foreground.opacity(0.7), + action: { + if let activeTab = tabManager.activeTab { + activeTab.goForward() + } + } + ) + .oraShortcutHelp("Go Forward", for: KeyboardShortcuts.Navigation.forward) + + URLBarButton( + systemName: "arrow.clockwise", + isEnabled: tabManager.activeTab != nil, + foregroundColor: theme.foreground.opacity(0.7), + action: { + if let activeTab = tabManager.activeTab { + activeTab.webView.reload() + } + } + ) + .oraShortcutHelp("Reload This Page", for: KeyboardShortcuts.Navigation.reload) + + if sidebarManager.sidebarPosition == .secondary { + HStack { + Spacer() + URLBarButton( + systemName: sidebarIcon, + isEnabled: tabManager.activeTab != nil, + foregroundColor: theme.foreground.opacity(0.7), + action: { sidebarManager.toggleSidebar() } + ) + .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + } + } + } + .padding(.trailing, 6) + .padding(.leading, sidebarManager.sidebarPosition == .primary ? 0 : 6) + .padding(.vertical, 0) + } + } + .padding(0) + } +} diff --git a/ora/Modules/Sidebar/SidebarURLDisplay.swift b/ora/Modules/Sidebar/SidebarURLDisplay.swift index d686cd2d..bba137c7 100644 --- a/ora/Modules/Sidebar/SidebarURLDisplay.swift +++ b/ora/Modules/Sidebar/SidebarURLDisplay.swift @@ -6,6 +6,7 @@ struct SidebarURLDisplay: View { @Environment(\.theme) private var theme @EnvironmentObject var tabManager: TabManager @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager let tab: Tab @Binding var editingURLString: String @@ -115,7 +116,7 @@ struct SidebarURLDisplay: View { .onChange(of: tab.url) { _, _ in if !isEditing { editingURLString = "" } } - .onChange(of: appState.showFullURL) { _, _ in + .onChange(of: toolbarManager.showFullURL) { _, _ in if !isEditing { editingURLString = "" } } .onChange(of: isEditing) { _, newValue in @@ -134,7 +135,7 @@ struct SidebarURLDisplay: View { } private func getDisplayURL() -> String { - if appState.showFullURL { + if toolbarManager.showFullURL { return tab.url.absoluteString } else { return tab.url.host ?? tab.url.absoluteString diff --git a/ora/Modules/Sidebar/SidebarView.swift b/ora/Modules/Sidebar/SidebarView.swift index de86db16..c1eb47da 100644 --- a/ora/Modules/Sidebar/SidebarView.swift +++ b/ora/Modules/Sidebar/SidebarView.swift @@ -4,17 +4,23 @@ import SwiftUI struct SidebarView: View { @Environment(\.theme) private var theme + @Environment(\.window) var window: NSWindow? @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var historyManger: HistoryManager + @EnvironmentObject var historyManager: HistoryManager @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var appState: AppState @EnvironmentObject var privacyMode: PrivacyMode @EnvironmentObject var media: MediaController + @EnvironmentObject var sidebarManager: SidebarManager + @EnvironmentObject var toolbarManager: ToolbarManager + @Query var containers: [TabContainer] - @Query(filter: nil, sort: [.init(\History.lastAccessedAt, order: .reverse)]) var histories: - [History] + @Query(filter: nil, sort: [.init(\History.lastAccessedAt, order: .reverse)]) + var histories: [History] + private let columns = Array(repeating: GridItem(spacing: 10), count: 3) - let isFullscreen: Bool + + @State private var isHoveringSidebarToggle = false private var shouldShowMediaWidget: Bool { let activeId = tabManager.activeTab?.id @@ -28,8 +34,10 @@ struct SidebarView: View { private var selectedContainerIndex: Binding { Binding( get: { - guard let activeContainer = tabManager.activeContainer else { return 0 } - return containers.firstIndex(where: { $0.id == activeContainer.id }) ?? 0 + guard let activeContainer = tabManager.activeContainer else { + return 0 + } + return containers.firstIndex { $0.id == activeContainer.id } ?? 0 }, set: { newIndex in guard newIndex >= 0, newIndex < containers.count else { return } @@ -40,6 +48,7 @@ struct SidebarView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { + SidebarToolbar() NSPageView( selection: selectedContainerIndex, pageObjects: containers, @@ -52,18 +61,20 @@ struct SidebarView: View { ) .padding(.horizontal, 10) .environmentObject(tabManager) - .environmentObject(historyManger) + .environmentObject(historyManager) .environmentObject(downloadManager) .environmentObject(appState) .environmentObject(privacyMode) + .environmentObject(toolbarManager) } - // Show player if there is at least one playing session not belonging to the active tab + if shouldShowMediaWidget { GlobalMediaPlayer() .environmentObject(media) .padding(.horizontal, 10) .transition(.move(edge: .bottom).combined(with: .opacity)) } + if !privacyMode.isPrivate { HStack { DownloadsWidget() @@ -78,12 +89,15 @@ struct SidebarView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding( EdgeInsets( - top: isFullscreen ? 10 : 36, + top: toolbarManager.isToolbarHidden ? 10 : 0, leading: 0, bottom: 10, trailing: 0 ) ) + .onTapGesture(count: 2) { + toggleMaximizeWindow() + } } private func onContainerSelected(container: TabContainer) { @@ -91,4 +105,8 @@ struct SidebarView: View { tabManager.activateContainer(container) } } + + private func toggleMaximizeWindow() { + window?.toggleMaximized() + } } diff --git a/ora/Modules/Sidebar/TabList/FavTabsList.swift b/ora/Modules/Sidebar/TabList/FavTabsList.swift index a51f34b0..68c095c0 100644 --- a/ora/Modules/Sidebar/TabList/FavTabsList.swift +++ b/ora/Modules/Sidebar/TabList/FavTabsList.swift @@ -11,6 +11,7 @@ struct FavTabsGrid: View { let onSelect: (Tab) -> Void let onFavoriteToggle: (Tab) -> Void let onClose: (Tab) -> Void + let onDuplicate: (Tab) -> Void let onMoveToContainer: ( Tab, @@ -45,6 +46,7 @@ struct FavTabsGrid: View { onTap: { onSelect(tab) }, onFavoriteToggle: { onFavoriteToggle(tab) }, onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, onMoveToContainer: { onMoveToContainer(tab, $0) } ) .onDrag { onDrag(tab.id) } diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index ee1551e4..00c4794e 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -9,6 +9,7 @@ struct NormalTabsList: View { let onPinToggle: (Tab) -> Void let onFavoriteToggle: (Tab) -> Void let onClose: (Tab) -> Void + let onDuplicate: (Tab) -> Void let onMoveToContainer: ( Tab, @@ -31,6 +32,7 @@ struct NormalTabsList: View { onPinToggle: { onPinToggle(tab) }, onFavoriteToggle: { onFavoriteToggle(tab) }, onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, onMoveToContainer: { onMoveToContainer(tab, $0) }, availableContainers: containers ) diff --git a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift index 93bebfef..63b16ab1 100644 --- a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift +++ b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift @@ -9,6 +9,7 @@ struct PinnedTabsList: View { let onPinToggle: (Tab) -> Void let onFavoriteToggle: (Tab) -> Void let onClose: (Tab) -> Void + let onDuplicate: (Tab) -> Void let onMoveToContainer: (Tab, TabContainer) -> Void let containers: [TabContainer] @EnvironmentObject var tabManager: TabManager @@ -33,6 +34,7 @@ struct PinnedTabsList: View { onPinToggle: { onPinToggle(tab) }, onFavoriteToggle: { onFavoriteToggle(tab) }, onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, onMoveToContainer: { onMoveToContainer(tab, $0) }, availableContainers: containers ) diff --git a/ora/Modules/SplitView/Split.swift b/ora/Modules/SplitView/Split.swift index 18c6618e..7008cbf1 100644 --- a/ora/Modules/SplitView/Split.swift +++ b/ora/Modules/SplitView/Split.swift @@ -109,7 +109,7 @@ public struct Split: View { } .clipped() // Can cause problems in some List styles if not clipped .environmentObject(layout) - // .onChange(of: fraction.value) { _, new in constrainedFraction = new } + .onChange(of: fraction.value) { _, new in constrainedFraction = new } } } diff --git a/ora/Modules/SplitView/SplitHolders.swift b/ora/Modules/SplitView/SplitHolders.swift index a9ddf604..92f9323c 100644 --- a/ora/Modules/SplitView/SplitHolders.swift +++ b/ora/Modules/SplitView/SplitHolders.swift @@ -82,6 +82,20 @@ public class FractionHolder: ObservableObject { setter: { fraction in UserDefaults.standard.set(fraction, forKey: key) } ) } + + public func inverted() -> FractionHolder { + FractionHolder( + 1.0 - value, + getter: { [weak self] in + guard let self else { return 0.5 } + return 1.0 - self.value + }, + setter: { [weak self] newValue in + guard let self else { return } + self.value = 1.0 - newValue + } + ) + } } /// An ObservableObject that `Split` view observes to change whether one of the `SplitSide`s is hidden. diff --git a/ora/Modules/TabSwitch/FloatingTabSwitcher.swift b/ora/Modules/TabSwitch/FloatingTabSwitcher.swift index 8a9bbac1..15665efc 100644 --- a/ora/Modules/TabSwitch/FloatingTabSwitcher.swift +++ b/ora/Modules/TabSwitch/FloatingTabSwitcher.swift @@ -172,7 +172,8 @@ struct FloatingTabSwitcher: View { isWebViewReady: tab.isWebViewReady, favicon: tab.favicon, faviconLocalFile: tab.faviconLocalFile, - textColor: theme.foreground + textColor: theme.foreground, + isPlayingMedia: tab.isPlayingMedia ) .frame(width: 16, height: 16) diff --git a/ora/Modules/URLBar/FloatingURLBar.swift b/ora/Modules/URLBar/FloatingURLBar.swift new file mode 100644 index 00000000..f953e3c3 --- /dev/null +++ b/ora/Modules/URLBar/FloatingURLBar.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct FloatingURLBar: View { + @Binding var showFloatingURLBar: Bool + @Binding var isMouseOverURLBar: Bool + + private var triggerAreaPadding: CGFloat { + showFloatingURLBar ? 50 : 16 + } + + var body: some View { + ZStack(alignment: .top) { + if showFloatingURLBar { + URLBar( + onSidebarToggle: { + NotificationCenter.default.post( + name: .toggleSidebar, object: nil + ) + } + ) + .shadow(color: Color.black.opacity(0.2), radius: 10, y: 4) + .overlay( + Rectangle() + .frame(height: 0.5) + .foregroundColor(Color(.separatorColor)), + alignment: .bottom + ) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) + } + + VStack(alignment: .leading) { + hoverStrip(width: .infinity) + Spacer() + } + .frame(maxWidth: .infinity) + .frame(height: triggerAreaPadding) + } + .animation(.easeInOut(duration: 0.1), value: showFloatingURLBar) + } + + @ViewBuilder + private func hoverStrip(width: CGFloat) -> some View { + Color.clear + .overlay( + GlobalMouseTrackingArea( + mouseEntered: Binding( + get: { showFloatingURLBar }, + set: { newValue in + withAnimation(.easeInOut(duration: 0.25)) { + isMouseOverURLBar = newValue + showFloatingURLBar = newValue + } + } + ), + edge: .top, + padding: triggerAreaPadding, + slack: 8 + ) + .id(triggerAreaPadding) + ) + } +} diff --git a/ora/OraCommands.swift b/ora/OraCommands.swift index 45a30612..d2f43193 100644 --- a/ora/OraCommands.swift +++ b/ora/OraCommands.swift @@ -1,37 +1,56 @@ -import AppKit import SwiftUI struct OraCommands: Commands { @AppStorage("AppAppearance") private var appearanceRaw: String = AppAppearance.system.rawValue + @AppStorage("ui.sidebar.hidden") private var isSidebarHidden: Bool = false + @AppStorage("ui.sidebar.position") private var sidebarPosition: SidebarPosition = .primary + @AppStorage("ui.toolbar.hidden") private var isToolbarHidden: Bool = false + @AppStorage("ui.toolbar.showfullurl") private var showFullURL: Bool = true @Environment(\.openWindow) private var openWindow - @ObservedObject private var shortcutManager = CustomKeyboardShortcutManager.shared var body: some Commands { CommandGroup(replacing: .newItem) { - Button("New Window") { - openWindow(id: "normal") - } - .keyboardShortcut(KeyboardShortcuts.Window.new.keyboardShortcut) + Button("New Window") { openWindow(id: "normal") } + .keyboardShortcut(KeyboardShortcuts.Window.new.keyboardShortcut) - Button("New Private Window") { - openWindow(id: "private") - } - .keyboardShortcut(KeyboardShortcuts.Window.newPrivate.keyboardShortcut) + Button("New Private Window") { openWindow(id: "private") } + .keyboardShortcut(KeyboardShortcuts.Window.newPrivate.keyboardShortcut) - Button("New Tab") { NotificationCenter.default.post(name: .showLauncher, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.new.keyboardShortcut) + Button("New Tab") { + NotificationCenter.default.post(name: .showLauncher, object: NSApp.keyWindow) + }.keyboardShortcut(KeyboardShortcuts.Tabs.new.keyboardShortcut) - Button("Close Tab") { NotificationCenter.default.post(name: .closeActiveTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.close.keyboardShortcut) + Divider() ImportDataButton() + + Divider() + + Button("Close Tab") { + NotificationCenter.default.post(name: .closeActiveTab, object: NSApp.keyWindow) + }.keyboardShortcut(KeyboardShortcuts.Tabs.close.keyboardShortcut) + + Button("Close Window") { + if let keyWindow = NSApp.keyWindow, keyWindow.title == "Settings" { + keyWindow.performClose(nil) + } + } + .keyboardShortcut("w", modifiers: .command) + .disabled({ + guard let keyWindow = NSApp.keyWindow else { return true } + return keyWindow.title != "Settings" + }()) } - CommandGroup(after: .pasteboard) { - Button("Restore") { NotificationCenter.default.post(name: .restoreLastTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.restore.keyboardShortcut) + CommandMenu("Edit") { + Button("Restore Last Tab") { + NotificationCenter.default.post(name: .restoreLastTab, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Tabs.restore.keyboardShortcut) + + Divider() - Button("Find") { + Button("Find in Page") { NotificationCenter.default.post(name: .findInPage, object: NSApp.keyWindow) } .keyboardShortcut(KeyboardShortcuts.Edit.find.keyboardShortcut) @@ -45,6 +64,7 @@ struct OraCommands: Commands { } CommandGroup(replacing: .sidebar) { + // APPEARANCE Picker("Appearance", selection: Binding( get: { AppAppearance(rawValue: appearanceRaw) ?? .system }, set: { newValue in @@ -57,125 +77,100 @@ struct OraCommands: Commands { } )) { ForEach(AppAppearance.allCases) { mode in - Text(mode.rawValue).tag(mode) + Text(mode.rawValue.capitalized).tag(mode) } } - } - CommandGroup(after: .sidebar) { - Button("Toggle Sidebar") { + Divider() + + // VISIBILITY + Button(isSidebarHidden ? "Show Sidebar" : "Hide Sidebar") { NotificationCenter.default.post(name: .toggleSidebar, object: nil) } .keyboardShortcut(KeyboardShortcuts.App.toggleSidebar.keyboardShortcut) - Divider() + Button(isToolbarHidden ? "Show Toolbar" : "Hide Toolbar") { + NotificationCenter.default.post(name: .toggleToolbar, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.App.toggleToolbar.keyboardShortcut) - Button("Toggle Full URL") { NotificationCenter.default.post(name: .toggleFullURL, object: NSApp.keyWindow) } - } + Divider() - CommandGroup(replacing: .appInfo) { - Button("About Ora") { showAboutWindow() } + // LAYOUT + Button(sidebarPosition == .primary ? "Right Side Tabs" : "Left Side Tabs") { + NotificationCenter.default.post(name: .toggleSidebarPosition, object: nil) + } - Button("Check for Updates") { NotificationCenter.default.post( - name: .checkForUpdates, - object: NSApp.keyWindow - ) } + Button(showFullURL ? "Hide Full URL" : "Show Full URL") { + NotificationCenter.default.post(name: .toggleFullURL, object: NSApp.keyWindow) + } + Divider() } CommandMenu("Navigation") { - Button("Reload") { NotificationCenter.default.post(name: .reloadPage, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Navigation.reload.keyboardShortcut) - Button("Back") { NotificationCenter.default.post(name: .goBack, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Navigation.back.keyboardShortcut) - Button("Forward") { NotificationCenter.default.post(name: .goForward, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Navigation.forward.keyboardShortcut) + Button("Reload Page") { + NotificationCenter.default.post(name: .reloadPage, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Navigation.reload.keyboardShortcut) + + Divider() + + Button("Back") { + NotificationCenter.default.post(name: .goBack, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Navigation.back.keyboardShortcut) + + Button("Forward") { + NotificationCenter.default.post(name: .goForward, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Navigation.forward.keyboardShortcut) } CommandMenu("Tabs") { - Button("New Tab") { NotificationCenter.default.post(name: .showLauncher, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.new.keyboardShortcut) - Button("Pin Tab") { NotificationCenter.default.post(name: .togglePinTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.pin.keyboardShortcut) + Button("Pin Tab") { + NotificationCenter.default.post(name: .togglePinTab, object: NSApp.keyWindow) + }.keyboardShortcut(KeyboardShortcuts.Tabs.pin.keyboardShortcut) Divider() - Button("Next Tab") { NotificationCenter.default.post(name: .nextTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.next.keyboardShortcut) - Button("Previous Tab") { NotificationCenter.default.post(name: .previousTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.previous.keyboardShortcut) + Button("Next Tab") { + NotificationCenter.default.post(name: .nextTab, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Tabs.next.keyboardShortcut) + + Button("Previous Tab") { + NotificationCenter.default.post(name: .previousTab, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Tabs.previous.keyboardShortcut) Divider() - Button("Tab 1") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 1] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab1.keyboardShortcut) - - Button("Tab 2") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 2] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab2.keyboardShortcut) - - Button("Tab 3") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 3] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab3.keyboardShortcut) - - Button("Tab 4") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 4] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab4.keyboardShortcut) - - Button("Tab 5") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 5] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab5.keyboardShortcut) - - Button("Tab 6") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 6] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab6.keyboardShortcut) - - Button("Tab 7") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 7] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab7.keyboardShortcut) - - Button("Tab 8") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 8] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab8.keyboardShortcut) - - Button("Tab 9") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 9] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab9.keyboardShortcut) + // Quick Tab Selection (1–9) + ForEach(1 ... 9, id: \.self) { index in + Button("Tab \(index)") { + NotificationCenter.default.post( + name: .selectTabAtIndex, + object: NSApp.keyWindow, + userInfo: ["index": index] + ) + } + .keyboardShortcut(KeyboardShortcuts.Tabs.keyboardShortcut(for: index)) + } } - CommandGroup(replacing: .toolbar) { - Button("Toggle Toolbar") { NotificationCenter.default.post(name: .toggleToolbar, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.App.toggleToolbar.keyboardShortcut) + CommandGroup(replacing: .appInfo) { + Button("About Ora") { showAboutWindow() } + Button("Check for Updates") { + NotificationCenter.default.post( + name: .checkForUpdates, + object: NSApp.keyWindow + ) + } } } + // MARK: - Utility Helpers + private func showAboutWindow() { let alert = NSAlert() alert.messageText = "Ora Browser" diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index e89f507c..05d14523 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -13,13 +13,15 @@ final class PrivacyMode: ObservableObject { struct OraRoot: View { @StateObject private var appState = AppState() @StateObject private var keyModifierListener = KeyModifierListener() - @StateObject private var appearanceManager = AppearanceManager() @StateObject private var updateService = UpdateService() @StateObject private var mediaController: MediaController @StateObject private var tabManager: TabManager @StateObject private var historyManager: HistoryManager @StateObject private var downloadManager: DownloadManager @StateObject private var privacyMode: PrivacyMode + @StateObject private var extensionManager = ExtensionManager.shared + @StateObject private var sidebarManager = SidebarManager() + @StateObject private var toolbarManager = ToolbarManager() let tabContext: ModelContext let historyContext: ModelContext @@ -28,19 +30,11 @@ struct OraRoot: View { init(isPrivate: Bool = false) { _privacyMode = StateObject(wrappedValue: PrivacyMode(isPrivate: isPrivate)) - let modelConfiguration = isPrivate ? ModelConfiguration(isStoredInMemoryOnly: true) : ModelConfiguration( - "OraData", - schema: Schema([TabContainer.self, History.self, Download.self]), - url: URL.applicationSupportDirectory.appending(path: "OraData.sqlite") - ) let container: ModelContainer let modelContext: ModelContext do { - container = try ModelContainer( - for: TabContainer.self, History.self, Download.self, - configurations: modelConfiguration - ) + container = try ModelConfiguration.createOraContainer(isPrivate: isPrivate) modelContext = ModelContext(container) } catch { deleteSwiftDataStore("OraData.sqlite") @@ -80,16 +74,28 @@ struct OraRoot: View { var body: some View { BrowserView() .background(WindowReader(window: $window)) + .background( + WindowAccessor( + isFullscreen: Binding( + get: { appState.isFullscreen }, + set: { newValue in appState.isFullscreen = newValue } + ) + ) + ) + .environment(\.window, window) .environmentObject(appState) .environmentObject(tabManager) .environmentObject(historyManager) .environmentObject(mediaController) .environmentObject(keyModifierListener) .environmentObject(CustomKeyboardShortcutManager.shared) - .environmentObject(appearanceManager) + .environmentObject(AppearanceManager.shared) .environmentObject(downloadManager) .environmentObject(updateService) .environmentObject(privacyMode) + .environmentObject(extensionManager) + .environmentObject(sidebarManager) + .environmentObject(toolbarManager) .modelContext(tabContext) .modelContext(historyContext) .modelContext(downloadContext) @@ -114,6 +120,11 @@ struct OraRoot: View { updateService.checkForUpdatesInBackground() } } + + ExtensionManager.shared.tabManager = tabManager + Task { + await extensionManager.loadAllExtensions() + } NotificationCenter.default.addObserver(forName: .showLauncher, object: nil, queue: .main) { note in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } if tabManager.activeTab != nil { @@ -134,12 +145,12 @@ struct OraRoot: View { } NotificationCenter.default.addObserver(forName: .toggleFullURL, object: nil, queue: .main) { note in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } - appState.showFullURL.toggle() + toolbarManager.showFullURL.toggle() } NotificationCenter.default.addObserver(forName: .toggleToolbar, object: nil, queue: .main) { note in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } withAnimation(.easeInOut(duration: 0.2)) { - appState.isToolbarHidden.toggle() + toolbarManager.isToolbarHidden.toggle() } } NotificationCenter.default.addObserver(forName: .reloadPage, object: nil, queue: .main) { note in @@ -171,7 +182,7 @@ struct OraRoot: View { if let raw = note.userInfo?["appearance"] as? String, let mode = AppAppearance(rawValue: raw) { - appearanceManager.appearance = mode + AppearanceManager.shared.appearance = mode } } NotificationCenter.default.addObserver(forName: .checkForUpdates, object: nil, queue: .main) { note in @@ -184,6 +195,22 @@ struct OraRoot: View { tabManager.selectTabAtIndex(index) } } + NotificationCenter.default.addObserver(forName: .openURL, object: nil, queue: .main) { note in + let targetWindow = window ?? NSApp.keyWindow + if let sender = note.object as? NSWindow { + guard sender === targetWindow else { return } + } else { + guard NSApp.keyWindow === targetWindow else { return } + } + guard let url = note.userInfo?["url"] as? URL else { return } + tabManager.openTab( + url: url, + historyManager: historyManager, + downloadManager: downloadManager, + focusAfterOpening: true, + isPrivate: privacyMode.isPrivate + ) + } } } } diff --git a/ora/Services/AppearanceManager.swift b/ora/Services/AppearanceManager.swift index 2a882a41..2b5cd3a1 100644 --- a/ora/Services/AppearanceManager.swift +++ b/ora/Services/AppearanceManager.swift @@ -10,19 +10,12 @@ enum AppAppearance: String, CaseIterable, Identifiable { class AppearanceManager: ObservableObject { static let shared = AppearanceManager() - @Published var appearance: AppAppearance { + @AppStorage("ui.app.appearance") var appearance: AppAppearance = .system { didSet { updateAppearance() - UserDefaults.standard.set(appearance.rawValue, forKey: "AppAppearance") } } - init() { - let saved = UserDefaults.standard.string(forKey: "AppAppearance") - self.appearance = AppAppearance(rawValue: saved ?? "") ?? .system - updateAppearance() - } - func updateAppearance() { guard NSApp != nil else { print("NSApp is nil, skipping appearance update") diff --git a/ora/Services/DefaultBrowserManager.swift b/ora/Services/DefaultBrowserManager.swift new file mode 100644 index 00000000..d993b8da --- /dev/null +++ b/ora/Services/DefaultBrowserManager.swift @@ -0,0 +1,53 @@ +// +// DefaultBrowserManager.swift +// ora +// +// Created by keni on 9/30/25. +// + +import AppKit +import Combine +import CoreServices + +class DefaultBrowserManager: ObservableObject { + static let shared = DefaultBrowserManager() + + @Published var isDefault: Bool = false + + private var cancellables = Set() + + private init() { + updateIsDefault() + // Periodically check if default browser status changed. I couldn't find another way. + Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.updateIsDefault() + } + .store(in: &cancellables) + } + + private func updateIsDefault() { + let newValue = Self.checkIsDefault() + if newValue != isDefault { + isDefault = newValue + } + } + + static func checkIsDefault() -> Bool { + guard let testURL = URL(string: "http://example.com"), + let appURL = NSWorkspace.shared.urlForApplication(toOpen: testURL), + let appBundle = Bundle(url: appURL) + else { + return false + } + + return appBundle.bundleIdentifier == Bundle.main.bundleIdentifier + } + + static func requestSetAsDefault() { + let bundleID = Bundle.main.bundleIdentifier! as CFString + LSSetDefaultHandlerForURLScheme("http" as CFString, bundleID) + LSSetDefaultHandlerForURLScheme("https" as CFString, bundleID) + } +} diff --git a/ora/Services/Extentions/ExtensionManager.swift b/ora/Services/Extentions/ExtensionManager.swift new file mode 100644 index 00000000..38c0d44a --- /dev/null +++ b/ora/Services/Extentions/ExtensionManager.swift @@ -0,0 +1,382 @@ +// +// OraExtensionManager.swift +// ora +// +// Created by keni on 9/17/25. +// + +import os.log +import SwiftUI +import WebKit + +// MARK: - Ora Extension Manager +class ExtensionManager: NSObject, ObservableObject { + static let shared = ExtensionManager() + + var controller: WKWebExtensionController + private let logger = Logger(subsystem: "com.ora.browser", category: "ExtensionManager") + + @Published var installedExtensions: [WKWebExtension] = [] + var extensionMap: [URL: WKWebExtension] = [:] + var tabManager: TabManager? + private var nextId: Int = 1 + private var nextWindowId: Int = 1 + private(set) var mainWindow: ExtensionWindowWrapper? + + override init() { + logger.info("Initializing OraExtensionManager") + let config = WKWebExtensionController.Configuration(identifier: UUID()) + controller = WKWebExtensionController(configuration: config) + super.init() + controller.delegate = self + logger.info("OraExtensionManager initialized successfully") + print("[ExtMgr] controller ready with identifier=\(config.identifier?.uuidString ?? "nil")") + } + + func nextTabId() -> Int { + let current = nextId + nextId += 1 + return current + } + + func nextWindowID() -> Int { + let current = nextWindowId + nextWindowId += 1 + return current + } + + @MainActor + func ensureWindowOpened() { + if mainWindow == nil { + let window = ExtensionWindowWrapper(id: nextWindowID()) + mainWindow = window + controller.didOpenWindow(window) + print("[ExtMgr] didOpenWindow id=\(window.id)") + } + } + + /// Install an extension from a local file + @MainActor + func installExtension(from url: URL) async { + logger.info("Starting extension installation from URL: \(url.path)") + + Task { + do { + logger.debug("Creating WKWebExtension from resource URL") + let webExtension = try await WKWebExtension(resourceBaseURL: url) + logger.debug("Extension created successfully: \(webExtension.displayName ?? "Unknown")") + print("[ExtMgr] created extension name=\(webExtension.displayName ?? "Unknown") base=\(url.path)") + + logger.debug("Creating WKWebExtensionContext") + let webContext = WKWebExtensionContext(for: webExtension) + webContext.isInspectable = true + print("[ExtMgr] context created for=\(webExtension.displayName ?? "Unknown")") + + logger.debug("Loading extension context into controller") + try controller.load(webContext) + print("[ExtMgr] context loaded for=\(webExtension.displayName ?? "Unknown")") + + // Load background content if available + webContext.loadBackgroundContent { [self] error in + if let error { + self.logger.error("Failed to load background content: \(error.localizedDescription)") + } else { + self.logger.debug("Background content loaded successfully") + } + } + + // Grant permissions + if let allUrlsPattern = try? WKWebExtension.MatchPattern(string: "") { + webContext.setPermissionStatus(.grantedExplicitly, for: allUrlsPattern) + logger.debug("Granted permission for extension") + print("[ExtMgr] granted for=\(webExtension.displayName ?? "Unknown")") + } + let storagePermission = WKWebExtension.Permission.storage + webContext.setPermissionStatus(.grantedExplicitly, for: storagePermission) + logger.debug("Granted storage permission for extension") + print("[ExtMgr] granted storage for=\(webExtension.displayName ?? "Unknown")") + + + let permissionsToGrant: [WKWebExtension.Permission] = [ + .activeTab, + .alarms, + .clipboardWrite, + .contextMenus, + .cookies, + .declarativeNetRequest, + .declarativeNetRequestFeedback, + .declarativeNetRequestWithHostAccess, + .menus, + .nativeMessaging, + .scripting, + .storage, + .tabs, + .unlimitedStorage, + .webNavigation, + .webRequest + ] + for permission in permissionsToGrant { + webContext.setPermissionStatus(.grantedExplicitly, for: permission) + print("[ExtMgr] granted \(permission) for=\(webExtension.displayName ?? "Unknown")") + } + + print("\(controller.extensionContexts.count) ctx") + print("\(controller.extensions.count) ext") + + logger.debug("Adding extension to installed extensions list") + installedExtensions.append(webExtension) + extensionMap[url] = webExtension + + logger.info("Extension installed successfully: \(webExtension.displayName ?? "Unknown")") + } catch { + logger.error("Failed to install extension from \(url.path): \(error.localizedDescription)") + print("❌ Failed to install extension: \(error)") + } + } + } + + /// Load all available extensions from the extensions directory + @MainActor + func loadAllExtensions() async { + logger.info("Loading all available extensions") + let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let extensionsDir = supportDir.appendingPathComponent("extensions") + + guard FileManager.default.fileExists(atPath: extensionsDir.path) else { + logger.info("Extensions directory does not exist, skipping load") + return + } + + do { + let contents = try FileManager.default.contentsOfDirectory( + at: extensionsDir, + includingPropertiesForKeys: [.isDirectoryKey] + ) + for url in contents { + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { + logger.debug("Loading extension from: \(url.path)") + await installExtension(from: url) + } + } + logger.info("Finished loading all extensions") + } catch { + logger.error("Failed to load extensions: \(error.localizedDescription)") + } + } + + /// Uninstall extension + func uninstallExtension(_ webExtension: WKWebExtension) { + logger.info("Uninstalling extension: \(webExtension.displayName ?? "Unknown")") + + // TODO: Implement proper unload when available + // controller.unload(webExtension) + + let removedCount = installedExtensions.count + installedExtensions.removeAll { $0 == webExtension } + extensionMap = extensionMap.filter { $0.value != webExtension } + let newCount = installedExtensions.count + + if removedCount > newCount { + logger.info("Extension uninstalled successfully. Remaining extensions: \(newCount)") + } else { + logger.warning("Extension not found in installed extensions list") + } + } +} + +// MARK: - Delegate for Permissions & Lifecycle +extension ExtensionManager: WKWebExtensionControllerDelegate { + + // When extension requests new permissions + func webExtensionController( + _ controller: WKWebExtensionController, + webExtension: WKWebExtension, + requestsAccessTo permissions: [WKWebExtension.Permission] + ) async -> Bool { + let extensionName = webExtension.displayName ?? "Unknown" + let permissionNames = permissions.map(\.rawValue).joined(separator: ", ") + + logger.info("Extension '\(extensionName)' requesting permissions: \(permissionNames)") + + // ✅ Show SwiftUI prompt to user + print("🔒 Extension \(extensionName) requests: \(permissionNames)") + + // TODO: Replace with real SwiftUI dialog + let granted = true // allow for now + logger.info("Permission request for '\(extensionName)' \(granted ? "granted" : "denied")") + + return granted + } + + // Handle background script messages + func webExtensionController( + _ controller: WKWebExtensionController, + webExtension: WKWebExtension, + didReceiveMessage message: Any, + from context: WKWebExtensionContext + ) { + let extensionName = webExtension.displayName ?? "Unknown" + logger.debug("Received message from extension '\(extensionName)': \(String(describing: message))") + + print("📩 Message from \(extensionName): \(message)") + + if let dict = message as? [String: Any] { + let api = dict["api"] as? String ?? "" + let method = dict["method"] as? String ?? "" + print("[ExtMgr] route api=\(api) method=\(method)") + } else { + print("[ExtMgr] non-dict message received") + } + + // Handle tab API messages + handleTabAPIMessage(message, from: context) + + logger.debug("Message processing completed for extension '\(extensionName)'") + } + + private func handleTabAPIMessage(_ message: Any, from context: WKWebExtensionContext) { + guard let dict = message as? [String: Any], + let api = dict["api"] as? String, api == "tabs", + let method = dict["method"] as? String, + let params = dict["params"] as? [String: Any] + else { + return + } + + guard let tabManager else { + logger.error("TabManager not available for extension tab API") + return + } + + Task { @MainActor in + switch method { + case "create": + handleTabsCreate(params: params, context: context) + case "remove": + handleTabsRemove(params: params, context: context) + case "update": + handleTabsUpdate(params: params, context: context) + case "query": + handleTabsQuery(params: params, context: context) + case "get": + handleTabsGet(params: params, context: context) + default: + logger.debug("Unknown tabs API method: \(method)") + } + } + } + + @MainActor + private func handleTabsCreate(params: [String: Any], context: WKWebExtensionContext) { + guard let urlString = params["url"] as? String, + let url = URL(string: urlString), + let container = tabManager?.activeContainer + else { + return + } + + let isPrivate = params["incognito"] as? Bool ?? false + let active = params["active"] as? Bool ?? true + + // Create history and download managers if needed + let historyManager = HistoryManager( + modelContainer: tabManager!.modelContainer, + modelContext: tabManager!.modelContext + ) + let downloadManager = DownloadManager( + modelContainer: tabManager!.modelContainer, + modelContext: tabManager!.modelContext + ) + + if active { + tabManager?.openTab( + url: url, + historyManager: historyManager, + downloadManager: downloadManager, + isPrivate: isPrivate + ) + } else { + _ = tabManager?.addTab( + url: url, + container: container, + historyManager: historyManager, + downloadManager: downloadManager, + isPrivate: isPrivate + ) + } + } + + @MainActor + private func handleTabsRemove(params: [String: Any], context: WKWebExtensionContext) { + guard let tabIdStrings = params["tabIds"] as? [String] else { return } + + for tabIdString in tabIdStrings { + if let tabId = UUID(uuidString: tabIdString), + let container = tabManager?.activeContainer, + let tab = container.tabs.first(where: { $0.id == tabId }) + { + tabManager?.closeTab(tab: tab) + } + } + } + + @MainActor + private func handleTabsUpdate(params: [String: Any], context: WKWebExtensionContext) { + guard let tabIdString = params["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString), + let container = tabManager?.activeContainer, + let tab = container.tabs.first(where: { $0.id == tabId }) else { return } + + // Update tab properties + if let urlString = params["url"] as? String, let url = URL(string: urlString) { + tab.url = url + tab.webView.load(URLRequest(url: url)) + } + } + + @MainActor + private func handleTabsQuery(params: [String: Any], context: WKWebExtensionContext) { + // Diagnostics: enumerate all containers and tabs + guard let tm = tabManager else { + print("[ExtMgr] tabs.query: tabManager is nil") + return + } + let containers = tm.containers + print("[ExtMgr] tabs.query: containers=\(containers.count)") + + var totalTabs = 0 + for (ci, container) in containers.enumerated() { + print("[ExtMgr] container[\(ci)] id=\(container.id) tabs=\(container.tabs.count)") + for (ti, tab) in container.tabs.enumerated() { + totalTabs += 1 + let wrapperId = tab.extensionTabWrapper?.id + let isActive = tm.isActive(tab) + print(" [ExtMgr] tab[\(ti)] uuid=\(tab.id) wrapperId=\(wrapperId ?? -1) active=\(isActive) url=\(tab.urlString)") + } + } + print("[ExtMgr] tabs.query: totalTabsEnumerated=\(totalTabs)") + + // Maintain previous behavior (no reply), but log a brief summary for active container + if let active = tm.activeContainer { + print("[ExtMgr] tabs.query: activeContainerTabs=\(active.tabs.count)") + } + } + + @MainActor + private func handleTabsGet(params: [String: Any], context: WKWebExtensionContext) { + guard let tabIdString = params["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString), + let container = tabManager?.activeContainer, + let tab = container.tabs.first(where: { $0.id == tabId }) else { return } + + let tabInfo: [String: Any] = [ + "id": tab.id.uuidString, + "url": tab.urlString, + "title": tab.title, + "active": tabManager?.isActive(tab) ?? false + ] + // Note: Cannot send response back to extension via WKWebExtensionContext + logger.debug("Tab get result: \(tabInfo)") + } +} diff --git a/ora/Services/Extentions/ExtensionTabWrapper.swift b/ora/Services/Extentions/ExtensionTabWrapper.swift new file mode 100644 index 00000000..b01ed795 --- /dev/null +++ b/ora/Services/Extentions/ExtensionTabWrapper.swift @@ -0,0 +1,125 @@ +// +// ExtensionTabWrapper.swift +// ora +// +// Created by keni on 10/9/25. +// +import WebKit +import Foundation +import AppKit // For NSImage + + +class ExtensionTabWrapper: NSObject, WKWebExtensionTab { + weak var nativeTab: Tab? // Weak to prevent cycles + let id: Int + + init(nativeTab: Tab, id: Int) { + self.nativeTab = nativeTab + self.id = id + print("[ExtTabWrapper] init wrapperId=\(id) tabId=\(nativeTab.id.uuidString) url=\(nativeTab.url.absoluteString)") + super.init() + } + + deinit { + print("[ExtTabWrapper] deinit wrapperId=\(id)") + } + +// // Required: Unique tab ID (manage via a counter in your app) +// var id: Int { +// get { self.id } // Backing storage +// // Note: This is read-only in the protocol, so no setter needed +// } + + // Core bridging: Expose the webView for injection + func webView(for context: WKWebExtensionContext) -> WKWebView? { + let extName = context.webExtension.displayName ?? "Unknown" + if let tab = nativeTab { + print("[ExtTabWrapper] webView(for:) wrapperId=\(id) tabId=\(tab.id.uuidString) ext=\(extName) url=\(tab.url.absoluteString)") + return tab.webView + } else { + print("[ExtTabWrapper] webView(for:) wrapperId=\(id) ext=\(extName) tab=nil") + return nil + } + } + + // Bridge loadURL: Translate to native method + func loadURL(_ url: URL, for context: WKWebExtensionContext, completionHandler: @escaping (Error?) -> Void) { + guard let nativeTab = nativeTab else { + completionHandler(NSError(domain: "ExtensionError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Tab no longer exists"])) + return + } + print("[ExtTabWrapper] loadURL wrapperId=\(id) tabId=\(nativeTab.id.uuidString) target=\(url.absoluteString)") + // Check permissions + guard context.permissionStatus(for: .activeTab) == .grantedExplicitly else { + completionHandler(NSError(domain: "PermissionError", code: -2, userInfo: [NSLocalizedDescriptionKey: "No permission to load URL"])) + return + } + nativeTab.loadURL(url.absoluteString) + // Assuming loadURL is fire-and-forget; call completion with no error + // If Tab.loadURL supports a completion, use it: nativeTab.loadURL(url.absoluteString) { error in completionHandler(error) } + completionHandler(nil) + } + +// // Bridge snapshot: Forward to native helper +// func takeSnapshot(using options: WKWebExtension.SnapshotOptions?, for context: WKWebExtensionContext, completionHandler: @escaping (NSImage?, Error?) -> Void) { +// guard let nativeTab = nativeTab else { +// completionHandler(nil, NSError(domain: "ExtensionError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Tab no longer exists"])) +// return +// } +// nativeTab.takeSnapshot { [weak self] image, error in +// // Optional: Permission/access check here if needed +// guard let self = self, self.nativeTab != nil else { +// completionHandler(nil, NSError(domain: "ExtensionError", code: -3, userInfo: [NSLocalizedDescriptionKey: "Tab invalidated during snapshot"])) +// return +// } +// completionHandler(image, error) +// } +// } + + // Bridge getters: Query native state + func url(for context: WKWebExtensionContext) -> URL? { + return nativeTab?.url + } + + func title(for context: WKWebExtensionContext) -> String? { + return nativeTab?.title + } + + // Required: Zoom factor (stub; delegate to native if available) + func zoomFactor(for context: WKWebExtensionContext) -> Double { + return 1.0 // Assume Tab has this; default to 1.0 + } + + // Required: Set zoom (stub) + func setZoomFactor(_ zoomFactor: Double, for context: WKWebExtensionContext, completionHandler: @escaping (Error?) -> Void) { + guard let nativeTab = nativeTab, zoomFactor >= 0.1 && zoomFactor <= 5.0 else { + completionHandler(NSError(domain: "ExtensionError", code: -4, userInfo: [NSLocalizedDescriptionKey: "Invalid zoom or tab missing"])) + return + } + // Delegate to native: nativeTab.setZoomFactor(zoomFactor) + completionHandler(nil) + } + +// // Required: Execute script (stub; use WKWebView's evaluateJavaScript) +// func executeScript(_ script: WKWebExtension.Script, for context: WKWebExtensionContext, completionHandler: @escaping (Any?, Error?) -> Void) { +// guard let webView = webView(for: context) else { +// completionHandler(nil, NSError(domain: "ExtensionError", code: -5, userInfo: [NSLocalizedDescriptionKey: "No web view available"])) +// return +// } +// webView.evaluateJavaScript(script.source) { result, error in +// completionHandler(result, error) +// } +// } + + // Required: Reader mode (stubs; implement if your Tab supports it) + func setReaderModeActive(_ active: Bool, for context: WKWebExtensionContext, completionHandler: @escaping (Error?) -> Void) { + // Delegate to nativeTab.setReaderModeActive(active) + completionHandler(nil) // Or handle error + } + + func isReaderModeAvailable(for context: WKWebExtensionContext) -> Bool { + return false + } + + // Add other required methods as needed (e.g., navigateBack, reload)... +} diff --git a/ora/Services/Extentions/ExtensionWindowWrapper.swift b/ora/Services/Extentions/ExtensionWindowWrapper.swift new file mode 100644 index 00000000..43727493 --- /dev/null +++ b/ora/Services/Extentions/ExtensionWindowWrapper.swift @@ -0,0 +1,19 @@ +import WebKit +import Foundation + +final class ExtensionWindowWrapper: NSObject, WKWebExtensionWindow { + let id: Int + + init(id: Int) { + self.id = id + super.init() + print("[ExtWindow] init id=\(id)") + } + + deinit { + print("[ExtWindow] deinit id=\(id)") + } +} + + + diff --git a/ora/Services/FaviconService.swift b/ora/Services/FaviconService.swift index e866896a..1035c440 100644 --- a/ora/Services/FaviconService.swift +++ b/ora/Services/FaviconService.swift @@ -3,16 +3,14 @@ import CoreImage import SwiftUI class FaviconService: ObservableObject { + static let shared = FaviconService() + private var cache: [String: NSImage] = [:] private var colorCache: [String: Color] = [:] func getFavicon(for searchURL: String) -> NSImage? { guard let domain = extractDomain(from: searchURL) else { return nil } - if let cachedFavicon = cache[domain] { - return cachedFavicon - } - // Try to fetch favicon asynchronously fetchFavicon(for: domain) { [weak self] favicon in if let favicon { @@ -24,6 +22,10 @@ class FaviconService: ObservableObject { } } + if let cachedFavicon = cache[domain] { + return cachedFavicon + } + return nil } @@ -47,7 +49,7 @@ class FaviconService: ObservableObject { } func faviconURL(for domain: String) -> URL? { - return URL(string: "https://www.google.com/s2/favicons?domain=\(domain)&sz=16") + return URL(string: "https://www.google.com/s2/favicons?domain=\(domain)&sz=64") } private func extractDomain(from searchURL: String) -> String? { @@ -64,12 +66,7 @@ class FaviconService: ObservableObject { } private func fetchFavicon(for domain: String, completion: @escaping (NSImage?) -> Void) { - let faviconURLs = [ - "https://www.google.com/s2/favicons?domain=\(domain)&sz=64", - "https://\(domain)/favicon.ico", - "https://\(domain)/apple-touch-icon.png" - ] - + let faviconURLs = self.getFaviconURLs(for: domain) tryFetchingFavicon(from: faviconURLs, index: 0, completion: completion) } @@ -92,6 +89,51 @@ class FaviconService: ObservableObject { } }.resume() } + + private func getFaviconURLs(for domain: String) -> [String] { + let faviconURLs = [ + "https://www.google.com/s2/favicons?domain=\(domain)&sz=64", + "https://\(domain)/favicon.ico", + "https://\(domain)/apple-touch-icon.png" + ] + return faviconURLs + } + + private func tryFetchingFaviconData(from urls: [String], index: Int, completion: @escaping (Data?, URL?) -> Void) { + guard index < urls.count else { + completion(nil, nil) + return + } + + guard let url = URL(string: urls[index]) else { + tryFetchingFaviconData(from: urls, index: index + 1, completion: completion) + return + } + + URLSession.shared.dataTask(with: url) { data, _, _ in + if let data { + completion(data, url) + } else { + self.tryFetchingFaviconData(from: urls, index: index + 1, completion: completion) + } + }.resume() + } + + func downloadAndSaveFavicon(for domain: String, to saveURL: URL, completion: @escaping (URL?, Bool) -> Void) { + let faviconURLs = self.getFaviconURLs(for: domain) + tryFetchingFaviconData(from: faviconURLs, index: 0) { data, url in + if let data, let url { + do { + try data.write(to: saveURL, options: .atomic) + completion(url, true) + } catch { + completion(nil, false) + } + } else { + completion(nil, false) + } + } + } } extension NSImage { diff --git a/ora/Services/MediaController.swift b/ora/Services/MediaController.swift index a9aacf4b..1459d74d 100644 --- a/ora/Services/MediaController.swift +++ b/ora/Services/MediaController.swift @@ -15,6 +15,7 @@ final class MediaController: ObservableObject { var canGoNext: Bool var canGoPrevious: Bool var lastActive: Date + var wasPlayed: Bool } // Published list of sessions ordered by recency (most recent first) @@ -40,7 +41,7 @@ final class MediaController: ObservableObject { // MARK: - Public accessors var primary: Session? { visibleSessions.first } - var visibleSessions: [Session] { sessions } + var visibleSessions: [Session] { sessions.filter(\.wasPlayed) } // MARK: - Receive events from JS bridge @@ -59,7 +60,8 @@ final class MediaController: ObservableObject { volume: 1.0, canGoNext: false, canGoPrevious: false, - lastActive: Date() + lastActive: Date(), + wasPlayed: false ) sessions.insert(session, at: 0) return 0 @@ -70,17 +72,16 @@ final class MediaController: ObservableObject { let idx = ensureSession() let playing = (event.state == "playing") sessions[idx].isPlaying = playing - if let newTitle = event.title, !newTitle.isEmpty { sessions[idx].title = newTitle } + // Update tab's isPlayingMedia property + tabRefs[tab.id]?.value?.isPlayingMedia = playing if let vol = event.volume { sessions[idx].volume = clamp(vol) } // Update recency when it starts playing if playing { sessions[idx].lastActive = Date() moveToFront(index: idx) } - - case "ready": - if let idx = sessions.firstIndex(where: { $0.tabID == id }) { - if let newTitle = event.title, !newTitle.isEmpty { sessions[idx].title = newTitle } - } + if let wasPlayed = event.wasPlayed { sessions[idx].wasPlayed = wasPlayed } +// case "ready": + // Session is already ensured in other cases case "volume": if let idx = sessions.firstIndex(where: { $0.tabID == id }), let vol = event.volume { @@ -96,14 +97,17 @@ final class MediaController: ObservableObject { case "ended": if let idx = sessions.firstIndex(where: { $0.tabID == id }) { sessions[idx].isPlaying = false + // Update tab's isPlayingMedia property + tabRefs[tab.id]?.value?.isPlayingMedia = false } - case "titleChange": - if let idx = sessions.firstIndex(where: { $0.tabID == id }), - let newTitle = event.title, !newTitle.isEmpty - { - sessions[idx].title = newTitle + case "removed": + if let idx = sessions.firstIndex(where: { $0.tabID == id }) { + sessions.remove(at: idx) } + // Update tab's isPlayingMedia property + tabRefs[tab.id]?.value?.isPlayingMedia = false + self.removeSession(for: tab.id) default: break @@ -155,10 +159,22 @@ final class MediaController: ObservableObject { if let idx = sessions.firstIndex(where: { $0.tabID == id }) { sessions.remove(at: idx) } + // Update tab's isPlayingMedia property + tabRefs[id]?.value?.isPlayingMedia = false tabRefs[id] = nil isVisible = !visibleSessions.isEmpty } + func removeSession(for tabID: UUID) { + if let idx = sessions.firstIndex(where: { $0.tabID == tabID }) { + sessions.remove(at: idx) + } + // Update tab's isPlayingMedia property + tabRefs[tabID]?.value?.isPlayingMedia = false + tabRefs[tabID] = nil + isVisible = !visibleSessions.isEmpty + } + // Helpers func volume(of tabID: UUID) -> Double { sessions.first(where: { $0.tabID == tabID })?.volume ?? 1.0 } func canGoNext(of tabID: UUID) -> Bool { sessions.first(where: { $0.tabID == tabID })?.canGoNext ?? false } @@ -190,13 +206,12 @@ final class MediaController: ObservableObject { private func syncTitlesForPlayingSessions() { let playingSessions = sessions.filter(\.isPlaying) for session in playingSessions { - fetchDocumentTitle(for: session.tabID) { [weak self] newTitle in - guard let self, let title = newTitle, !title.isEmpty, - let idx = self.sessions.firstIndex(where: { $0.tabID == session.tabID }), - title != self.sessions[idx].title - else { return } - - self.sessions[idx].title = title + if let tab = tabRefs[session.tabID]?.value, + let idx = sessions.firstIndex(where: { $0.tabID == session.tabID }), + !tab.title.isEmpty, + tab.title != sessions[idx].title + { + sessions[idx].title = tab.title } } } @@ -211,35 +226,25 @@ final class MediaController: ObservableObject { private func scheduleTitleSync(for tabID: UUID, attempts: Int = 6, delay: TimeInterval = 0.25) { guard attempts > 0 else { return } - let currentTitle = sessions.first(where: { $0.tabID == tabID })?.title - fetchDocumentTitle(for: tabID) { [weak self] newTitle in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } - if let title = newTitle, !title.isEmpty, title != currentTitle, - let idx = self.sessions.firstIndex(where: { $0.tabID == tabID }) + if let tab = self.tabRefs[tabID]?.value, + let idx = self.sessions.firstIndex(where: { $0.tabID == tabID }), + !tab.title.isEmpty, + tab.title != self.sessions[idx].title { - self.sessions[idx].title = title - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - self.scheduleTitleSync(for: tabID, attempts: attempts - 1, delay: delay) - } + self.sessions[idx].title = tab.title + } else if attempts > 1 { + self.scheduleTitleSync(for: tabID, attempts: attempts - 1, delay: delay) } } } - - private func fetchDocumentTitle(for tabID: UUID, completion: @escaping (String?) -> Void) { - guard let webView = tabRefs[tabID]?.value?.webView else { completion(nil) - return - } - let js = "(function(){ try { return (window.__oraMedia && window.__oraMedia.title && window.__oraMedia.title()) || document.title || ''; } catch(e) { return document.title || ''; } })()" - webView.evaluateJavaScript(js) { result, _ in - completion(result as? String) - } - } } // Payload from injected JS struct MediaEventPayload: Codable { let type: String + let wasPlayed: Bool? let state: String? let volume: Double? let title: String? diff --git a/ora/Services/SearchEngineService.swift b/ora/Services/SearchEngineService.swift index c4b3e617..708b4283 100644 --- a/ora/Services/SearchEngineService.swift +++ b/ora/Services/SearchEngineService.swift @@ -44,6 +44,21 @@ class SearchEngineService: ObservableObject { return settingsStore } + /// All built-in search engine IDs derived from the built-in engines + var builtInEngineIDs: [SearchEngineID] { + return builtInSearchEngines.compactMap { SearchEngineID(rawValue: $0.name) } + } + + /// Check if a name corresponds to a built-in search engine + func isBuiltInEngine(_ name: String) -> Bool { + return builtInSearchEngines.contains { $0.name == name } + } + + /// Get SearchEngineID from engine name if it exists + func getSearchEngineID(from name: String) -> SearchEngineID? { + return SearchEngineID(rawValue: name) + } + var builtInSearchEngines: [SearchEngine] { [ SearchEngine( @@ -76,10 +91,35 @@ class SearchEngineService: ObservableObject { color: .blue, icon: "", aliases: ["google", "goo", "g", "search"], - searchURL: "https://www.google.com/search?client=safari&rls=en&ie=UTF-8&oe=UTF-8&q={query}", + searchURL: + "https://www.google.com/search?client=safari&rls=en&ie=UTF-8&oe=UTF-8&q={query}", isAIChat: false, autoSuggestions: self.googleSuggestions ), + SearchEngine( + name: "DuckDuckGo", + color: Color(hex: "#DE5833"), + icon: "", + aliases: ["duckduckgo", "ddg", "duck"], + searchURL: "https://duckduckgo.com/?q={query}", + isAIChat: false + ), + SearchEngine( + name: "Kagi", + color: Color(hex: "#FFB319"), + icon: "", + aliases: ["kagi", "kg"], + searchURL: "https://kagi.com/search?q={query}", + isAIChat: false + ), + SearchEngine( + name: "Bing", + color: Color(hex: "#02B7E9"), + icon: "", + aliases: ["bing", "b", "microsoft"], + searchURL: "https://www.bing.com/search?q={query}", + isAIChat: false + ), SearchEngine( name: "Grok", color: theme?.foreground ?? .white, @@ -168,7 +208,7 @@ class SearchEngineService: ObservableObject { icon: "", aliases: custom.aliases, searchURL: custom.searchURL, - isAIChat: false + isAIChat: custom.isAIChat ) } @@ -217,6 +257,10 @@ class SearchEngineService: ObservableObject { return searchEngines.first(where: { $0.name == engineName.rawValue }) } + func getSearchEngine(byName name: String) -> SearchEngine? { + return searchEngines.first(where: { $0.name == name }) + } + func getSearchURLForEngine(engineName: SearchEngineID, query: String) -> URL? { if let engine = getSearchEngine(engineName) { if let url = createSearchURL( @@ -230,28 +274,34 @@ class SearchEngineService: ObservableObject { } func createSearchURL(for engine: SearchEngine, query: String) -> URL? { - let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let encodedQuery = + query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let urlString = engine.searchURL.replacingOccurrences(of: "{query}", with: encodedQuery) return URL(string: urlString) } func createSearchURL(for match: LauncherMain.Match, query: String) -> URL? { - let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let encodedQuery = + query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let urlString = match.searchURL.replacingOccurrences(of: "{query}", with: encodedQuery) return URL(string: urlString) } func createSuggestionsURL(urlString: String, query: String) -> URL? { - let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let encodedQuery = + query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let urlString = urlString.replacingOccurrences(of: "{query}", with: encodedQuery) return URL(string: urlString) } func googleSuggestions(_ query: String) async -> [String] { - guard let url = createSuggestionsURL( - urlString: "https://suggestqueries.google.com/complete/search?client=firefox&q={query}", - query: query - ) else { + guard + let url = createSuggestionsURL( + urlString: + "https://suggestqueries.google.com/complete/search?client=firefox&q={query}", + query: query + ) + else { return [] } diff --git a/ora/Services/SectionDropDelegate.swift b/ora/Services/SectionDropDelegate.swift index a258d8d2..e6a70639 100644 --- a/ora/Services/SectionDropDelegate.swift +++ b/ora/Services/SectionDropDelegate.swift @@ -24,9 +24,18 @@ struct SectionDropDelegate: DropDelegate { if self.items.isEmpty { // Section is empty, just change type and order - from.type = tabType(for: self.targetSection) + let newType = tabType(for: self.targetSection) + from.type = newType + // Update savedURL when moving into pinned/fav; clear when moving to normal + switch newType { + case .pinned, .fav: + from.savedURL = from.url + case .normal: + from.savedURL = nil + } let maxOrder = container.tabs.max(by: { $0.order < $1.order })?.order ?? 0 from.order = maxOrder + 1 + try? self.tabManager.modelContext.save() } // else if let to = self.items.last { // if isInSameSection(from: from, to: to) { diff --git a/ora/Services/SidebarManager.swift b/ora/Services/SidebarManager.swift new file mode 100644 index 00000000..8ac6eb1e --- /dev/null +++ b/ora/Services/SidebarManager.swift @@ -0,0 +1,40 @@ +import SwiftUI + +enum SidebarPosition: String, Hashable { + case primary + case secondary +} + +@MainActor +class SidebarManager: ObservableObject { + @AppStorage("ui.sidebar.hidden") var isSidebarHidden: Bool = false + @AppStorage("ui.sidebar.position") var sidebarPosition: SidebarPosition = .primary + + @Published var primaryFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction.primary") + @Published var secondaryFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction.secondary") + @Published var hiddenSidebar = SideHolder.usingUserDefaults(key: "ui.sidebar.visibility") + + var currentFraction: FractionHolder { + sidebarPosition == .primary ? primaryFraction : secondaryFraction + } + + func updateSidebarHidden() { + isSidebarHidden = hiddenSidebar.side == .primary || hiddenSidebar.side == .secondary + } + + func toggleSidebar() { + let targetSide = sidebarPosition == .primary ? SplitSide.primary : .secondary + withAnimation(.spring(response: 0.2, dampingFraction: 1.0)) { + hiddenSidebar.side = (hiddenSidebar.side == targetSide) ? nil : targetSide + updateSidebarHidden() + } + } + + func toggleSidebarPosition() { + let isCurrentSidebarHidden = hiddenSidebar.side == (sidebarPosition == .primary ? .primary : .secondary) + sidebarPosition = sidebarPosition == .primary ? .secondary : .primary + if isCurrentSidebarHidden { + hiddenSidebar.side = sidebarPosition == .primary ? .primary : .secondary + } + } +} diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index a3d82aa2..b1a95e84 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -1,6 +1,13 @@ import AppKit import SwiftUI +extension Array where Element: Hashable { + func unique() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} + struct TabDropDelegate: DropDelegate { let item: Tab // to @Binding var draggedItem: UUID? @@ -15,7 +22,21 @@ struct TabDropDelegate: DropDelegate { let uuid = UUID(uuidString: string) { DispatchQueue.main.async { - guard let from = self.item.container.tabs.first(where: { $0.id == uuid }) else { return } + // First try to find the tab in the target container + var from = self.item.container.tabs.first(where: { $0.id == uuid }) + + // If not found, try to find it in all containers of the same type + if from == nil { + // Look through all tabs in all containers to find the dragged tab + for container in self.item.container.tabs.compactMap(\.container).unique() { + if let foundTab = container.tabs.first(where: { $0.id == uuid }) { + from = foundTab + break + } + } + } + + guard let from else { return } if isInSameSection( from: from, diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 31d41e6d..ce846e84 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -7,89 +7,69 @@ import WebKit @MainActor class TabManager: ObservableObject { @Published var activeContainer: TabContainer? - @Published var activeTab: Tab? + @Published var activeTab: Tab? { + willSet { + guard let tab = activeTab, SettingsStore.shared.autoPiPEnabled else { return } + tab.webView.evaluateJavaScript("window.__oraTriggerPiP()") + } + didSet { + guard let tab = activeTab, SettingsStore.shared.autoPiPEnabled else { return } + tab.webView.evaluateJavaScript("window.__oraTriggerPiP(true)") + } + } + let modelContainer: ModelContainer let modelContext: ModelContext let mediaController: MediaController + var recentTabs: [Tab] { + guard let container = activeContainer else { return [] } + return Array(container.tabs + .sorted { ($0.lastAccessedAt ?? Date.distantPast) > ($1.lastAccessedAt ?? Date.distantPast) } + .prefix(SettingsStore.shared.maxRecentTabs) + ) + } + + var tabsToRender: [Tab] { + guard let container = activeContainer else { return [] } + let specialTabs = container.tabs.filter { $0.type == .pinned || $0.type == .fav || $0.isPlayingMedia } + let combined = Set(recentTabs + specialTabs) + return Array(combined) + } + + // Note: Could be made injectable via init parameter if preferred + let tabSearchingService: TabSearchingProviding + @Query(sort: \TabContainer.lastAccessedAt, order: .reverse) var containers: [TabContainer] + private var cleanupTimer: Timer? + init( modelContainer: ModelContainer, modelContext: ModelContext, - mediaController: MediaController + mediaController: MediaController, + tabSearchingService: TabSearchingProviding = TabSearchingService() ) { self.modelContainer = modelContainer self.modelContext = modelContext self.mediaController = mediaController + self.tabSearchingService = tabSearchingService self.modelContext.undoManager = UndoManager() initializeActiveContainerAndTab() - } - - func search(_ text: String) -> [Tab] { - let activeContainerId = activeContainer?.id ?? UUID() - let trimmedText = text.trimmingCharacters(in: .whitespaces) - - let predicate: Predicate - if trimmedText.isEmpty { - predicate = #Predicate { _ in true } - } else { - predicate = #Predicate { tab in - ( - tab.urlString.localizedStandardContains(trimmedText) || - tab.title - .localizedStandardContains( - trimmedText - ) - ) && tab.container.id == activeContainerId - } - } - - let descriptor = FetchDescriptor(predicate: predicate) - - do { - let results = try modelContext.fetch(descriptor) - let now = Date() - - return results.sorted { result1, result2 in - let result1Score = combinedScore(for: result1, query: trimmedText, now: now) - let result2Score = combinedScore(for: result2, query: trimmedText, now: now) - return result1Score > result2Score - } - - } catch { - return [] - } - } - - private func combinedScore(for tab: Tab, query: String, now: Date) -> Double { - let match = scoreMatch(tab, text: query) - - let timeInterval: TimeInterval = if let accessedAt = tab.lastAccessedAt { - now.timeIntervalSince(accessedAt) - } else { - 1_000_000 // far in the past → lowest recency - } - let recencyBoost = max(0, 1_000_000 - timeInterval) - return Double(match * 1000) + recencyBoost + // Start automatic cleanup timer (every minute) + startCleanupTimer() } - private func scoreMatch(_ tab: Tab, text: String) -> Int { - let text = text.lowercased() - let title = tab.title.lowercased() - let url = tab.urlString.lowercased() - - func score(_ field: String) -> Int { - if field == text { return 100 } - if field.hasPrefix(text) { return 90 } - if field.contains(text) { return 75 } - if text.contains(field) { return 50 } - return 0 - } + // MARK: - Public API's - return max(score(title), score(url)) + func search(_ text: String) -> [Tab] { + tabSearchingService.search( + text, + activeContainer: activeContainer, + modelContext: modelContext + ) } func openFromEngine( @@ -137,9 +117,7 @@ class TabManager: ObservableObject { try? modelContext.save() } - func getActiveTab() -> Tab? { - return self.activeTab - } + // MARK: - Container Public API's func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { tab.container = toContainer @@ -167,7 +145,8 @@ class TabManager: ObservableObject { } } - func createContainer(name: String = "Default", emoji: String = "💩") -> TabContainer { + @discardableResult + func createContainer(name: String = "Default", emoji: String = "•") -> TabContainer { let newContainer = TabContainer(name: name, emoji: emoji) modelContext.insert(newContainer) activeContainer = newContainer @@ -187,9 +166,32 @@ class TabManager: ObservableObject { modelContext.delete(container) } + func activateContainer(_ container: TabContainer, activateLastAccessedTab: Bool = true) { + activeContainer = container + container.lastAccessedAt = Date() + + // Set the most recently accessed tab in the container + if let lastAccessedTab = container.tabs + .sorted(by: { $0.lastAccessedAt ?? Date() > $1.lastAccessedAt ?? Date() }).first, + lastAccessedTab.isWebViewReady + { + activeTab?.maybeIsActive = false + activeTab = lastAccessedTab + activeTab?.maybeIsActive = true + lastAccessedTab.lastAccessedAt = Date() + } else { + activeTab = nil + } + + try? modelContext.save() + } + + // MARK: - Tab Public API's + func addTab( title: String = "Untitled", - url: URL = URL(string: "https://www.youtube.com/") ?? URL(string: "about:blank") ?? URL(fileURLWithPath: ""), + /// Will Always Work + url: URL = URL(string: "about:blank")!, container: TabContainer, favicon: URL? = nil, historyManager: HistoryManager? = nil, @@ -223,7 +225,10 @@ class TabManager: ObservableObject { // Initialize the WebView for the new active tab newTab.restoreTransientState( - historyManger: historyManager ?? HistoryManager(modelContainer: modelContainer, modelContext: modelContext), + historyManager: historyManager ?? HistoryManager( + modelContainer: modelContainer, + modelContext: modelContext + ), downloadManager: downloadManager ?? DownloadManager( modelContainer: modelContainer, modelContext: modelContext @@ -241,8 +246,9 @@ class TabManager: ObservableObject { historyManager: HistoryManager, downloadManager: DownloadManager? = nil, focusAfterOpening: Bool = true, - isPrivate: Bool - ) { + isPrivate: Bool, + loadSilently: Bool = false + ) -> Tab? { if let container = activeContainer { if let host = url.host { let faviconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(host)") @@ -270,10 +276,11 @@ class TabManager: ObservableObject { activeTab = newTab activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() - + } + if focusAfterOpening || loadSilently { // Initialize the WebView for the new active tab newTab.restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager ?? DownloadManager( modelContainer: modelContainer, modelContext: modelContext @@ -285,8 +292,10 @@ class TabManager: ObservableObject { container.lastAccessedAt = Date() try? modelContext.save() + return newTab } } + return nil } func reorderTabs(from: Tab, toTab: Tab) { @@ -323,7 +332,7 @@ class TabManager: ObservableObject { { activeTab? .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: tab.isPrivate @@ -338,6 +347,7 @@ class TabManager: ObservableObject { tab.isWebViewReady = false tab.destroyWebView() } + self.mediaController.removeSession(for: tab.id) try? self.modelContext.save() } } @@ -358,26 +368,6 @@ class TabManager: ObservableObject { try? modelContext.save() // Persist the undo operation } - func activateContainer(_ container: TabContainer, activateLastAccessedTab: Bool = true) { - activeContainer = container - container.lastAccessedAt = Date() - - // Set the most recently accessed tab in the container - if let lastAccessedTab = container.tabs - .sorted(by: { $0.lastAccessedAt ?? Date() > $1.lastAccessedAt ?? Date() }).first, - lastAccessedTab.isWebViewReady - { - activeTab?.maybeIsActive = false - activeTab = lastAccessedTab - activeTab?.maybeIsActive = true - lastAccessedTab.lastAccessedAt = Date() - } else { - activeTab = nil - } - - try? modelContext.save() - } - func activateTab(_ tab: Tab) { activeTab?.maybeIsActive = false activeTab = tab @@ -385,8 +375,75 @@ class TabManager: ObservableObject { tab.lastAccessedAt = Date() activeContainer = tab.container tab.container.lastAccessedAt = Date() + + // Lazy load WebView if not ready + if !tab.isWebViewReady { + tab.restoreTransientState( + historyManager: tab.historyManager ?? HistoryManager( + modelContainer: modelContainer, + modelContext: modelContext + ), + downloadManager: tab.downloadManager ?? DownloadManager( + modelContainer: modelContainer, + modelContext: modelContext + ), + tabManager: self, + isPrivate: tab.isPrivate + ) + } tab.updateHeaderColor() + try? modelContext.save() + // Note: Controller API has no setActive; skipping explicit activation. + } + + /// Clean up old tabs that haven't been accessed recently to preserve memory + func cleanupOldTabs() { + let timeout = SettingsStore.shared.tabAliveTimeout + // Skip cleanup if set to "Never" (365 days) + guard timeout < 365 * 24 * 60 * 60 else { return } + + let allContainers = fetchContainers() + for container in allContainers { + for tab in container.tabs { + if !tab.isAlive, tab.isWebViewReady, tab.id != activeTab?.id, !tab.isPlayingMedia, tab.type == .normal { + tab.destroyWebView() + } + } + } + } + + /// Completely remove old normal tabs that haven't been accessed for a long time + func removeOldTabs() { + let cutoffDate = Date().addingTimeInterval(-SettingsStore.shared.tabRemovalTimeout) + let allContainers = fetchContainers() + + for container in allContainers { + for tab in container.tabs { + if let lastAccessed = tab.lastAccessedAt, + lastAccessed < cutoffDate, + tab.id != activeTab?.id, + !tab.isPlayingMedia, + tab.type == .normal + { + closeTab(tab: tab) + } + } + } + } + + /// Start the automatic cleanup timer + private func startCleanupTimer() { + cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in + DispatchQueue.main.async { + self?.cleanupOldTabs() + self?.removeOldTabs() + } + } + } + + deinit { + cleanupTimer?.invalidate() } // Activate a tab by its persistent id. If the tab is in a @@ -446,7 +503,7 @@ class TabManager: ObservableObject { if message.name == "listener", let url = message.body as? String { - // You can update the active tab’s url if needed + // You can update the active tab's url if needed DispatchQueue.main.async { if let validURL = URL(string: url) { self.activeTab?.url = validURL @@ -458,4 +515,101 @@ class TabManager: ObservableObject { } } } + + func duplicateTab(_ tab: Tab) { + // Create a new tab using the existing openTab method + guard let historyManager = tab.historyManager else { return } + guard let newTab = openTab( + url: tab.url, + historyManager: historyManager, + downloadManager: tab.downloadManager, + focusAfterOpening: false, + isPrivate: tab.isPrivate, + loadSilently: true + ) else { return } + self.reorderTabs(from: tab, toTab: newTab) + } +} + +// MARK: - Tab Searching Providing + +protocol TabSearchingProviding { + func search( + _ text: String, + activeContainer: TabContainer?, + modelContext: ModelContext + ) -> [Tab] +} + +// MARK: - Tab Searching Service + +final class TabSearchingService: TabSearchingProviding { + func search( + _ text: String, + activeContainer: TabContainer? = nil, + modelContext: ModelContext + ) -> [Tab] { + let activeContainerId = activeContainer?.id ?? UUID() + let trimmedText = text.trimmingCharacters(in: .whitespaces) + + let predicate: Predicate + if trimmedText.isEmpty { + predicate = #Predicate { _ in true } + } else { + predicate = #Predicate { tab in + ( + tab.urlString.localizedStandardContains(trimmedText) || + tab.title + .localizedStandardContains( + trimmedText + ) + ) && tab.container.id == activeContainerId + } + } + + let descriptor = FetchDescriptor(predicate: predicate) + + do { + let results = try modelContext.fetch(descriptor) + let now = Date() + + return results.sorted { result1, result2 in + let result1Score = combinedScore(for: result1, query: trimmedText, now: now) + let result2Score = combinedScore(for: result2, query: trimmedText, now: now) + return result1Score > result2Score + } + + } catch { + return [] + } + } + + private func combinedScore(for tab: Tab, query: String, now: Date) -> Double { + let match = scoreMatch(tab, text: query) + + let timeInterval: TimeInterval = if let accessedAt = tab.lastAccessedAt { + now.timeIntervalSince(accessedAt) + } else { + 1_000_000 // far in the past → lowest recency + } + + let recencyBoost = max(0, 1_000_000 - timeInterval) + return Double(match * 1000) + recencyBoost + } + + private func scoreMatch(_ tab: Tab, text: String) -> Int { + let text = text.lowercased() + let title = tab.title.lowercased() + let url = tab.urlString.lowercased() + + func score(_ field: String) -> Int { + if field == text { return 100 } + if field.hasPrefix(text) { return 90 } + if field.contains(text) { return 75 } + if text.contains(field) { return 50 } + return 0 + } + + return max(score(title), score(url)) + } } diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift index 6aaa3f3f..2450acd2 100644 --- a/ora/Services/TabScriptHandler.swift +++ b/ora/Services/TabScriptHandler.swift @@ -62,6 +62,15 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { self?.mediaController?.receive(event: payload, from: tab) } } + } else if message.name == "downloadExtension" { + guard let body = message.body as? [String: Any], + let urlString = body["url"] as? String, + let url = URL(string: urlString), + let tab = self.tab + else { return } + Task { + await downloadAndInstallExtension(from: url, tab: tab) + } } } @@ -71,12 +80,22 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15" configuration.applicationNameForUserAgent = userAgent + configuration.allowsInlinePredictions = false // Enable JavaScript configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") configuration.preferences.setValue(true, forKey: "allowsPictureInPictureMediaPlayback") configuration.preferences.setValue(true, forKey: "javaScriptEnabled") configuration.preferences.setValue(true, forKey: "javaScriptCanOpenWindowsAutomatically") + configuration.preferences.setValue(true, forKey: "pushAPIEnabled") + configuration.preferences.setValue(true, forKey: "notificationsEnabled") + configuration.preferences.setValue(true, forKey: "notificationEventEnabled") + configuration.preferences.setValue(true, forKey: "fullScreenEnabled") + + // configuration.preferences.setValue(false, forKey: "allowsAutomaticSpellingCorrection") + // configuration.preferences.setValue(false, forKey: "allowsAutomaticTextReplacement") + // configuration.preferences.setValue(false, forKey: "allowsAutomaticQuoteSubstitution") + // configuration.preferences.setValue(false, forKey: "allowsAutomaticDashSubstitution") if temporaryStorage { configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() } else { @@ -84,21 +103,24 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { forIdentifier: containerId ) } + + configuration.webExtensionController = ExtensionManager.shared.controller + // Performance optimizations configuration.allowsAirPlayForMediaPlayback = true configuration.preferences.javaScriptCanOpenWindowsAutomatically = false // Enable process pool for better memory management - let processPool = WKProcessPool() - configuration.processPool = processPool - // video shit - configuration.preferences.isElementFullscreenEnabled = true - if #unavailable(macOS 10.12) { - // Picture in picture not available on older macOS versions - } else { -// configuration.allowsPictureInPictureMediaPlaybook = true - } +// let processPool = WKProcessPool() +// configuration.processPool = processPool +// // video shit +// configuration.preferences.isElementFullscreenEnabled = true +// if #unavailable(macOS 10.12) { +// // Picture in picture not available on older macOS versions +// } else { + //// configuration.allowsPictureInPictureMediaPlaybook = true +// } // Enable media playback without user interaction configuration.mediaTypesRequiringUserActionForPlayback = [] @@ -113,11 +135,64 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { contentController.add(self, name: "listener") contentController.add(self, name: "linkHover") contentController.add(self, name: "mediaEvent") + contentController.add(self, name: "downloadExtension") configuration.userContentController = contentController return configuration } + private func downloadAndInstallExtension(from url: URL, tab: Tab) async { + logger.info("Downloading extension from: \(url.absoluteString)") + + // Download the file + guard let (data, response) = try? await URLSession.shared.data(from: url), + let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 + else { + logger.error("Failed to download extension") + return + } + + // Save to temp file + let tempDir = FileManager.default.temporaryDirectory + let tempZipURL = tempDir.appendingPathComponent("downloaded_extension.zip") + try? data.write(to: tempZipURL) + + // Extract + let extensionsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("extensions") + if !FileManager.default.fileExists(atPath: extensionsDir.path) { + try? FileManager.default.createDirectory(at: extensionsDir, withIntermediateDirectories: true) + } + + // Create subfolder named after the file or something + let zipName = url.deletingPathExtension().lastPathComponent + let extractDir = extensionsDir.appendingPathComponent(zipName) + if !FileManager.default.fileExists(atPath: extractDir.path) { + try? FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) + } + + // Extract using unzip + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", tempZipURL.path, "-d", extractDir.path] + try? process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + logger.info("Extraction successful, installing extension") + await ExtensionManager.shared.installExtension(from: extractDir) + // Reload the tab + DispatchQueue.main.async { + tab.webView.reload() + } + } else { + logger.error("Extraction failed") + } + + // Clean up temp file + try? FileManager.default.removeItem(at: tempZipURL) + } + deinit { // Optional cleanup logger.debug("TabScriptHandler deinitialized") diff --git a/ora/Services/ToolbarManager.swift b/ora/Services/ToolbarManager.swift new file mode 100644 index 00000000..aa1405f0 --- /dev/null +++ b/ora/Services/ToolbarManager.swift @@ -0,0 +1,7 @@ +import Foundation +import SwiftUI + +class ToolbarManager: ObservableObject { + @AppStorage("ui.toolbar.hidden") var isToolbarHidden: Bool = false + @AppStorage("ui.toolbar.showfullurl") var showFullURL: Bool = true +} diff --git a/ora/Services/WebViewNavigationDelegate.swift b/ora/Services/WebViewNavigationDelegate.swift index 50577ce7..3f09e59b 100644 --- a/ora/Services/WebViewNavigationDelegate.swift +++ b/ora/Services/WebViewNavigationDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import Foundation import os.log import SwiftUI @preconcurrency import WebKit @@ -111,36 +112,46 @@ let navigationScript = """ const stateFrom = (el) => ({ type: 'state', + wasPlayed: el && el.__oraWasPlayed, state: el && !el.paused ? 'playing' : 'paused', volume: el ? (el.muted ? 0 : el.volume) : undefined, title: document.title }); - - // Enhanced title change monitoring for media sessions - let lastMediaTitle = document.title; - function checkTitleChange() { - if (document.title !== lastMediaTitle) { - lastMediaTitle = document.title; - // If any media is currently playing, send a title update - const activeMedia = document.querySelector('video:not([paused]), audio:not([paused])'); - if (activeMedia) { - post({ type: 'titleChange', title: document.title }); + function watchRemoval(element, callback) { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const removed of mutation.removedNodes) { + if (removed === element || removed.contains(element)) { + callback(); + observer.disconnect(); + return; + } + } } - } + }); + + observer.observe(document.body, { childList: true, subtree: true }); } function attach(el) { if (!el || el.__oraAttached) return; el.__oraAttached = true; const update = () => post(stateFrom(el)); - el.addEventListener('play', update); + el.addEventListener('play', ()=>{ + update(); + el.__oraWasPlayed = true; + }); el.addEventListener('pause', update); el.addEventListener('ended', () => post({ type: 'ended' })); el.addEventListener('volumechange', () => post({ type: 'volume', volume: el.muted ? 0 : el.volume }) ); // If already playing, announce - if (!el.paused) update(); + if (!el.paused) { + el.__oraWasPlayed = true; + update(); + } + watchRemoval(el, () => post({ type: 'removed' })); } function scan() { @@ -152,9 +163,6 @@ let navigationScript = """ mo.observe(document.documentElement, { childList: true, subtree: true }); scan(); - // Set up periodic title checking for active media - setInterval(checkTitleChange, 1000); - window.__oraMedia = { active: null, _pick() { @@ -218,6 +226,31 @@ let navigationScript = """ return document.title; } }; + window.__oraTriggerPiP = function(isActive = false) { + const video = document.querySelector('video'); + + function hasAudio(video) { + if (!video) return false; + if (video.audioTracks && video.audioTracks.length > 0) return true; + if (!video.muted && video.volume > 0) return true; + return false; + } + + if ( + video && + video.tagName === 'VIDEO' && + !document.pictureInPictureElement && + !video.paused && + !isActive && + hasAudio(video) + ) { + video.requestPictureInPicture() + .catch(e => {}); + } else if (document.pictureInPictureElement) { + document.exitPictureInPicture() + .catch(e => {}); + } + }; post({ type: 'ready', title: document.title }); })(); @@ -302,6 +335,7 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { onChange?(webView.title, webView.url) onProgressChange?(webView.estimatedProgress * 100.0) webView.evaluateJavaScript(navigationScript, completionHandler: nil) + injectDownloadScriptIfNeeded(webView) takeSnapshotAfterLoad(webView) originalURL = nil // Clear stored URL after successful navigation } @@ -405,6 +439,106 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { } } + private func injectDownloadScriptIfNeeded(_ webView: WKWebView) { + guard let url = webView.url, url.host?.contains("mozilla.org") == true else { return } + + // Inject script to add a fixed floating download button if .xpi links are found + let downloadScript = """ + (function() { + const xpiLinks = document.querySelectorAll('a[href*=".xpi"]'); + if (xpiLinks.length > 0 && !document.querySelector('.ora-floating-download-btn')) { + const button = document.createElement('button'); + button.innerText = 'Download to Ora!'; + button.className = 'ora-floating-download-btn'; + button.style.position = 'fixed'; + button.style.top = '20px'; + button.style.right = '20px'; + button.style.zIndex = '10000'; + button.style.padding = '12px 20px'; + button.style.background = 'linear-gradient(45deg, #ff6b35, #f7931e, #ff4757)'; + button.style.color = 'white'; + button.style.border = '3px solid #fff'; + button.style.borderRadius = '25px'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 4px 15px rgba(255, 107, 53, 0.4)'; + button.style.fontWeight = 'bold'; + button.style.fontSize = '14px'; + button.style.textTransform = 'uppercase'; + button.style.letterSpacing = '1px'; + button.style.transition = 'all 0.3s ease'; + button.onmouseover = function() { button.style.transform = 'scale(1.05)'; button.style.boxShadow = '0 6px 20px rgba(255, 107, 53, 0.6)'; }; + button.onmouseout = function() { button.style.transform = 'scale(1)'; button.style.boxShadow = '0 4px 15px rgba(255, 107, 53, 0.4)'; }; + button.onclick = function(e) { + const link = xpiLinks[0]; // Download the first .xpi link + window.webkit.messageHandlers.downloadExtension.postMessage({url: link.href}); + }; + document.body.appendChild(button); + } + })(); + """ + webView.evaluateJavaScript(downloadScript, completionHandler: nil) + } + + private func downloadAndInstallExtension(from url: URL, tab: Tab) async { + logger.info("Downloading extension from: \(url.absoluteString)") + + // Download the file + guard let (data, response) = try? await URLSession.shared.data(from: url), + let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 + else { + logger.error("Failed to download extension") + return + } + + // Save to temp file + let tempDir = FileManager.default.temporaryDirectory + let tempFileURL = tempDir.appendingPathComponent("downloaded_extension.xpi") + do { + try data.write(to: tempFileURL) + } catch { + logger.error("Failed to save temp file: \(error)") + return + } + + // Extract + let extensionsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("extensions") + if !FileManager.default.fileExists(atPath: extensionsDir.path) { + try? FileManager.default.createDirectory(at: extensionsDir, withIntermediateDirectories: true) + } + + // Create subfolder named after the file + let fileName = url.deletingPathExtension().lastPathComponent + let extractDir = extensionsDir.appendingPathComponent(fileName) + if !FileManager.default.fileExists(atPath: extractDir.path) { + try? FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) + } + + // Extract using unzip + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", tempFileURL.path, "-d", extractDir.path] + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus == 0 { + logger.info("Extraction successful, installing extension") + await ExtensionManager.shared.installExtension(from: extractDir) + // Reload the tab + DispatchQueue.main.async { + tab.webView.reload() + } + } else { + logger.error("Extraction failed") + } + } catch { + logger.error("Failed to extract: \(error)") + } + + // Clean up temp file + try? FileManager.default.removeItem(at: tempFileURL) + } + private func extractDominantColor(from cgImage: CGImage) -> NSColor? { let width = cgImage.width let height = cgImage.height diff --git a/ora/UI/Buttons/URLBarButton.swift b/ora/UI/Buttons/URLBarButton.swift index da6d545b..eaeefec0 100644 --- a/ora/UI/Buttons/URLBarButton.swift +++ b/ora/UI/Buttons/URLBarButton.swift @@ -17,7 +17,7 @@ struct URLBarButton: View { ) .frame(width: 30, height: 30) .background( - RoundedRectangle(cornerRadius: 6) + ConditionallyConcentricRectangle(cornerRadius: 6) .fill(isHovering && isEnabled ? foregroundColor.opacity(0.2) : Color.clear) ) } diff --git a/ora/UI/EmptyFavTabItem.swift b/ora/UI/EmptyFavTabItem.swift index cb315dc2..7f3baa09 100644 --- a/ora/UI/EmptyFavTabItem.swift +++ b/ora/UI/EmptyFavTabItem.swift @@ -4,6 +4,8 @@ struct EmptyFavTabItem: View { @Environment(\.theme) var theme @State private var isTargeted = false + let cornerRadius: CGFloat = 8 + var body: some View { VStack(spacing: 8) { Image(systemName: "star") @@ -18,9 +20,9 @@ struct EmptyFavTabItem: View { .frame(maxWidth: .infinity, alignment: .center) .frame(height: 96) .background(theme.invertedSolidWindowBackgroundColor.opacity(0.07)) - .cornerRadius(10) + .cornerRadius(cornerRadius) .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) + ConditionallyConcentricRectangle(cornerRadius: cornerRadius) .stroke( theme.invertedSolidWindowBackgroundColor.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [5, 5]) diff --git a/ora/UI/FavTabItem.swift b/ora/UI/FavTabItem.swift index 86a0f501..82d1bc5a 100644 --- a/ora/UI/FavTabItem.swift +++ b/ora/UI/FavTabItem.swift @@ -9,6 +9,7 @@ struct FavTabItem: View { let onTap: () -> Void let onFavoriteToggle: () -> Void let onClose: () -> Void + let onDuplicate: () -> Void let onMoveToContainer: (TabContainer) -> Void @Environment(\.theme) private var theme @@ -42,13 +43,33 @@ struct FavTabItem: View { textColor: Color(.white) ) } + + if tab.isPlayingMedia { + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: "speaker.wave.2.fill") + .resizable() + .scaledToFit() + .frame(width: 8, height: 8) + .foregroundColor(.white.opacity(0.9)) + .background( + Circle() + .fill(Color.black.opacity(0.6)) + .frame(width: 12, height: 12) + ) + } + } + .padding(2) + } } .onTapGesture { onTap() if !tab.isWebViewReady { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -59,7 +80,7 @@ struct FavTabItem: View { if tabManager.isActive(tab) { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -92,7 +113,7 @@ struct FavTabItem: View { if !tab.isWebViewReady { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -105,6 +126,10 @@ struct FavTabItem: View { Label("Remove from Favorites", systemImage: "star.slash") } + Button(action: onDuplicate) { + Label("Duplicate Tab", systemImage: "doc.on.doc") + } + // Divider() // Menu("Move to Container") { diff --git a/ora/UI/HomeView.swift b/ora/UI/HomeView.swift index 5d1e9fad..e7cf2f85 100644 --- a/ora/UI/HomeView.swift +++ b/ora/UI/HomeView.swift @@ -1,8 +1,8 @@ import SwiftUI struct HomeView: View { - let sidebarToggle: () -> Void @Environment(\.theme) var theme + @EnvironmentObject private var sidebarManager: SidebarManager var body: some View { ZStack(alignment: .top) { @@ -15,15 +15,19 @@ struct HomeView: View { BlurEffectView(material: .underWindowBackground, blendingMode: .behindWindow) ) - URLBarButton( - systemName: "sidebar.left", - isEnabled: true, - foregroundColor: theme.foreground.opacity(0.3), - action: { sidebarToggle() } - ) - .oraShortcut(KeyboardShortcuts.App.toggleSidebar) - .position(x: 20, y: 20) + HStack { + URLBarButton( + systemName: "sidebar.left", + isEnabled: true, + foregroundColor: theme.foreground.opacity(0.3), + action: { sidebarManager.toggleSidebar() } + ) + .oraShortcut(KeyboardShortcuts.App.toggleSidebar) + } .zIndex(3) + .frame(maxWidth: .infinity, alignment: sidebarManager.sidebarPosition == .primary ? .leading : .trailing) + .padding(6) + .ignoresSafeArea(.all) VStack(alignment: .center, spacing: 16) { Image("ora-logo-plain") diff --git a/ora/UI/NSPageView.swift b/ora/UI/NSPageView.swift index fcdb3d17..f4e204b5 100644 --- a/ora/UI/NSPageView.swift +++ b/ora/UI/NSPageView.swift @@ -5,7 +5,7 @@ import SwiftUI import os.log private let logger = Logger( - subsystem: "com.juniperphoton.photonutilityview", category: "NSPageView" + subsystem: "com.orabrowser.app", category: "NSPageView" ) /// A ``NSViewControllerRepresentable`` for showing ``NSPageController``. @@ -48,13 +48,9 @@ import SwiftUI return contentView(object) } controller.idToObject = { [weak controller] id in - // Should apply weak reference to the controller to prevent circle causing memory leak. guard let controller else { return nil } - // We should refer to controller.pageObjects to get the updated objects, in which controller is a - // reference type. - // Since NSPageView is a struct type, which can't be captured in the block. return controller.pageObjects.first { page in let pageId = page[keyPath: idKeyPath] return pageId == id @@ -137,8 +133,6 @@ import SwiftUI super.viewDidLoad() self.delegate = self self.updateDataSource() - - // Configure the page controller to handle horizontal gestures with higher priority self.view.wantsLayer = true } @@ -148,9 +142,6 @@ import SwiftUI view.frame = self.view.bounds } - // When our container changes size due to SwiftUI layout (e.g., Split resizing), - // NSPageController may not immediately resize its current page until a transition occurs. - // Force-complete the transition so the current page view adopts the new bounds. let currentSize = self.view.bounds.size if currentSize != previousBoundsSize { previousBoundsSize = currentSize @@ -213,7 +204,7 @@ import SwiftUI var content: ((T) -> V)? var object: T? - private var hostingView: NSHostingView? + private var hostingView: NSHostingView? override func loadView() { self.view = NSView() @@ -226,7 +217,8 @@ import SwiftUI return } let view = content(object) - let hostingView = NSHostingView(rootView: view) + let wrappedView = AnyView(view.ignoresSafeArea()) + let hostingView = NSHostingView(rootView: wrappedView) hostingView.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(hostingView) NSLayoutConstraint.activate([ diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index 336acb13..7047d44d 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -45,9 +45,10 @@ struct FavIcon: View { let favicon: URL? let faviconLocalFile: URL? let textColor: Color + var isPlayingMedia: Bool = false var body: some View { - HStack { + HStack(spacing: 4) { if let favicon, isWebViewReady { AsyncImage( url: favicon @@ -68,8 +69,16 @@ struct FavIcon: View { textColor: textColor ) } + + if isPlayingMedia { + Image(systemName: "speaker.wave.2.fill") + .resizable() + .scaledToFit() + .frame(width: 8, height: 8) + .foregroundColor(textColor.opacity(0.8)) + } } - .frame(width: 16, height: 16) + .frame(width: isPlayingMedia ? 28 : 16, height: 16) } } @@ -81,6 +90,7 @@ struct TabItem: View { let onPinToggle: () -> Void let onFavoriteToggle: () -> Void let onClose: () -> Void + let onDuplicate: () -> Void let onMoveToContainer: (TabContainer) -> Void @EnvironmentObject var tabManager: TabManager @EnvironmentObject var historyManager: HistoryManager @@ -97,7 +107,8 @@ struct TabItem: View { isWebViewReady: tab.isWebViewReady, favicon: tab.favicon, faviconLocalFile: tab.faviconLocalFile, - textColor: textColor + textColor: textColor, + isPlayingMedia: tab.isPlayingMedia ) tabTitle Spacer() @@ -107,7 +118,7 @@ struct TabItem: View { if tabManager.isActive(tab) { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -120,7 +131,7 @@ struct TabItem: View { if !tab.isWebViewReady { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -147,7 +158,7 @@ struct TabItem: View { if !tab.isWebViewReady { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -208,23 +219,28 @@ struct TabItem: View { ) } + Button(action: onDuplicate) { + Label("Duplicate Tab", systemImage: "doc.on.doc") + } + .disabled(!tab.isWebViewReady) + Divider() - Menu("Move to Container") { - ForEach(availableContainers) { container in - if tab.container.id != tabManager.activeContainer?.id { - Button(action: { onMoveToContainer(tab.container) }) { - Label { - Text(container.name) - } icon: { - Text(container.emoji) // This is where you show the emoji + if availableContainers.count > 1 { + Divider() + + Menu("Move to Container") { + ForEach(availableContainers) { container in + if tab.container.id != container.id { + Button(action: { onMoveToContainer(container) }) { + Text(container.emoji.isEmpty ? container.name : "\(container.emoji) \(container.name)") } } } } - } - Divider() + Divider() + } Button(role: .destructive, action: onClose) { Label("Close Tab", systemImage: "xmark") diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 36a74371..cb765087 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -1,19 +1,90 @@ import AppKit import SwiftUI +import WebKit + +struct ExtensionIconView: NSViewRepresentable { + let iconName: String + let tooltip: String + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.toolTip = tooltip + imageView.isEditable = false + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.toolTip = tooltip + } +} + +struct ExtensionIconButton: NSViewRepresentable { + let ext: WKWebExtension + let extensionManager: ExtensionManager + + func makeNSView(context: Context) -> NSButton { + let button = NSButton() + button.image = ext.icon(for: NSSize(width: 32, height: 32)) + button.imageScaling = .scaleProportionallyUpOrDown + button.toolTip = ext.displayName ?? "Extension" + button.isBordered = false + button.bezelStyle = .regularSquare + button.target = context.coordinator + button.action = #selector(Coordinator.clicked(_:)) + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { + nsView.image = ext.icon(for: NSSize(width: 32, height: 32)) + nsView.toolTip = ext.displayName ?? "Extension" + } + + func makeCoordinator() -> Coordinator { + Coordinator(ext: ext, extensionManager: extensionManager) + } + + class Coordinator: NSObject { + let ext: WKWebExtension + let extensionManager: ExtensionManager + + init(ext: WKWebExtension, extensionManager: ExtensionManager) { + self.ext = ext + self.extensionManager = extensionManager + } + + @objc func clicked(_ sender: NSButton) { + guard let context = extensionManager.controller.extensionContexts.first(where: { $0.webExtension == ext }), + let action = context.action(for: nil), action.presentsPopup, + let popover = action.popupPopover + else { + print("No popup for extension: \(ext.displayName ?? "Unknown")") + return + } + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: NSRectEdge.maxY) + } + } +} // MARK: - URLBar struct URLBar: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var appState: AppState + @EnvironmentObject var extensionManager: ExtensionManager + @EnvironmentObject var sidebarManager: SidebarManager + @EnvironmentObject var toolbarManager: ToolbarManager @State private var showCopiedAnimation = false @State private var startWheelAnimation = false @State private var editingURLString: String = "" @FocusState private var isEditing: Bool @Environment(\.colorScheme) var colorScheme + @State private var alertMessage: String? let onSidebarToggle: () -> Void + let size: CGSize = .init(width: 32, height: 32) private func getForegroundColor(_ tab: Tab) -> Color { // Convert backgroundColor to NSColor for luminance calculation @@ -45,7 +116,7 @@ struct URLBar: View { } private func getDisplayURL(_ tab: Tab) -> String { - if appState.showFullURL { + if toolbarManager.showFullURL { return tab.url.absoluteString } else { return tab.url.host ?? tab.url.absoluteString @@ -65,17 +136,42 @@ struct URLBar: View { } } + private var extensionIconsView: some View { + HStack(spacing: 4) { + ForEach(extensionManager.installedExtensions, id: \.self) { ext in + ExtensionIconButton(ext: ext, extensionManager: extensionManager) + .frame(width: size.width, height: size.height) + } + } + .padding(.horizontal, 4) + .alert("Notice", isPresented: .constant(alertMessage != nil), actions: { + Button("OK", role: .cancel) { + alertMessage = nil + } + }, message: { + if let message = alertMessage { + Text(message) + } + }) + } + var body: some View { HStack { if let tab = tabManager.activeTab { HStack(spacing: 4) { - URLBarButton( - systemName: "sidebar.left", - isEnabled: true, - foregroundColor: buttonForegroundColor, - action: onSidebarToggle - ) - .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + if toolbarManager.isToolbarHidden || sidebarManager.sidebarPosition == .secondary { + WindowControls(isFullscreen: appState.isFullscreen) + } + + if sidebarManager.sidebarPosition == .primary { + URLBarButton( + systemName: "sidebar.left", + isEnabled: true, + foregroundColor: buttonForegroundColor, + action: onSidebarToggle + ) + .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + } // Back button URLBarButton( @@ -204,7 +300,7 @@ struct URLBar: View { .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(getUrlFieldColor(tab).opacity(0.12)) + .fill(getUrlFieldColor(tab).opacity(0.08)) .overlay( RoundedRectangle(cornerRadius: 6, style: .continuous) .stroke( @@ -223,6 +319,11 @@ struct URLBar: View { .allowsHitTesting(false) ) + // Extension icons + if !extensionManager.installedExtensions.isEmpty { + extensionIconsView + } + ShareLinkButton( isEnabled: true, foregroundColor: buttonForegroundColor, @@ -239,6 +340,16 @@ struct URLBar: View { foregroundColor: buttonForegroundColor, action: {} ) + + if sidebarManager.sidebarPosition == .secondary { + URLBarButton( + systemName: "sidebar.right", + isEnabled: true, + foregroundColor: buttonForegroundColor, + action: onSidebarToggle + ) + .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + } } .padding(4) .onAppear { @@ -252,7 +363,7 @@ struct URLBar: View { editingURLString = getDisplayURL(tab) } } - .onChange(of: appState.showFullURL) { _, _ in + .onChange(of: toolbarManager.showFullURL) { _, _ in if !isEditing, let tab = tabManager.activeTab { editingURLString = getDisplayURL(tab) } diff --git a/ora/UI/WindowControls.swift b/ora/UI/WindowControls.swift new file mode 100644 index 00000000..0d8c0f29 --- /dev/null +++ b/ora/UI/WindowControls.swift @@ -0,0 +1,71 @@ +import AppKit +import SwiftUI + +enum WindowControlType { + case close, minimize, zoom +} + +struct WindowControls: View { + @State private var isHovered = false + let isFullscreen: Bool + + var body: some View { + if !isFullscreen { + HStack(spacing: 9) { + WindowControlButton(type: .close, isHovered: $isHovered) + WindowControlButton(type: .minimize, isHovered: $isHovered) + WindowControlButton(type: .zoom, isHovered: $isHovered) + } + .padding(.horizontal, 8) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.1)) { + isHovered = hovering + } + } + } else { + EmptyView() + } + } +} + +struct WindowControlButton: View { + let type: WindowControlType + @Binding var isHovered: Bool + + private var buttonSize: CGFloat { + if #available(macOS 26.0, *) { + return 14 + } else { + return 12 + } + } + + private var assetBaseName: String { + switch type { + case .close: return "close" + case .minimize: return "minimize" + case .zoom: return "maximize" + } + } + + var body: some View { + Image(isHovered ? "\(assetBaseName)-hover" : "\(assetBaseName)-normal") + .resizable() + .frame(width: buttonSize, height: buttonSize) + .onTapGesture { + performAction() + } + } + + private func performAction() { + guard let window = NSApp.keyWindow else { return } + switch type { + case .close: + window.performClose(nil) + case .minimize: + window.performMiniaturize(nil) + case .zoom: + window.toggleFullScreen(nil) + } + } +} diff --git a/ora/WindowControls.xcassets/Contents.json b/ora/WindowControls.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ora/WindowControls.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/close-hover.imageset/Close Hover Icon.svg b/ora/WindowControls.xcassets/close-hover.imageset/Close Hover Icon.svg new file mode 100644 index 00000000..c7460baf --- /dev/null +++ b/ora/WindowControls.xcassets/close-hover.imageset/Close Hover Icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ora/WindowControls.xcassets/close-hover.imageset/Contents.json b/ora/WindowControls.xcassets/close-hover.imageset/Contents.json new file mode 100644 index 00000000..7d0673a5 --- /dev/null +++ b/ora/WindowControls.xcassets/close-hover.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Close Hover Icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/close-normal.imageset/Contents.json b/ora/WindowControls.xcassets/close-normal.imageset/Contents.json new file mode 100644 index 00000000..98a7e010 --- /dev/null +++ b/ora/WindowControls.xcassets/close-normal.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "close-normal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/close-normal.imageset/close-normal.svg b/ora/WindowControls.xcassets/close-normal.imageset/close-normal.svg new file mode 100644 index 00000000..193b6649 --- /dev/null +++ b/ora/WindowControls.xcassets/close-normal.imageset/close-normal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/WindowControls.xcassets/maximize-hover.imageset/Contents.json b/ora/WindowControls.xcassets/maximize-hover.imageset/Contents.json new file mode 100644 index 00000000..5dc51319 --- /dev/null +++ b/ora/WindowControls.xcassets/maximize-hover.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "maximize-hover.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/maximize-hover.imageset/maximize-hover.svg b/ora/WindowControls.xcassets/maximize-hover.imageset/maximize-hover.svg new file mode 100644 index 00000000..3e0cfdff --- /dev/null +++ b/ora/WindowControls.xcassets/maximize-hover.imageset/maximize-hover.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ora/WindowControls.xcassets/maximize-normal.imageset/Contents.json b/ora/WindowControls.xcassets/maximize-normal.imageset/Contents.json new file mode 100644 index 00000000..63e19945 --- /dev/null +++ b/ora/WindowControls.xcassets/maximize-normal.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "maximize-normal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/maximize-normal.imageset/maximize-normal.svg b/ora/WindowControls.xcassets/maximize-normal.imageset/maximize-normal.svg new file mode 100644 index 00000000..bc189e8c --- /dev/null +++ b/ora/WindowControls.xcassets/maximize-normal.imageset/maximize-normal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/WindowControls.xcassets/minimize-hover.imageset/Contents.json b/ora/WindowControls.xcassets/minimize-hover.imageset/Contents.json new file mode 100644 index 00000000..cd7f007d --- /dev/null +++ b/ora/WindowControls.xcassets/minimize-hover.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "minimize-hover.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/minimize-hover.imageset/minimize-hover.svg b/ora/WindowControls.xcassets/minimize-hover.imageset/minimize-hover.svg new file mode 100644 index 00000000..b16697b3 --- /dev/null +++ b/ora/WindowControls.xcassets/minimize-hover.imageset/minimize-hover.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ora/WindowControls.xcassets/minimize-normal.imageset/Contents.json b/ora/WindowControls.xcassets/minimize-normal.imageset/Contents.json new file mode 100644 index 00000000..70e4a6a8 --- /dev/null +++ b/ora/WindowControls.xcassets/minimize-normal.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "minimize-normal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/minimize-normal.imageset/minimize-normal.svg b/ora/WindowControls.xcassets/minimize-normal.imageset/minimize-normal.svg new file mode 100644 index 00000000..8200155c --- /dev/null +++ b/ora/WindowControls.xcassets/minimize-normal.imageset/minimize-normal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/WindowControls.xcassets/no-focus.imageset/Contents.json b/ora/WindowControls.xcassets/no-focus.imageset/Contents.json new file mode 100644 index 00000000..9338045c --- /dev/null +++ b/ora/WindowControls.xcassets/no-focus.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "no-focus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/no-focus.imageset/no-focus.svg b/ora/WindowControls.xcassets/no-focus.imageset/no-focus.svg new file mode 100644 index 00000000..b5642243 --- /dev/null +++ b/ora/WindowControls.xcassets/no-focus.imageset/no-focus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/oraApp.swift b/ora/oraApp.swift index 7e736374..7a2df56e 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -9,6 +9,30 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSWindow.allowsAutomaticWindowTabbing = false AppearanceManager.shared.updateAppearance() } + + func application(_ application: NSApplication, open urls: [URL]) { + handleIncomingURLs(urls) + } + + func getWindow() -> NSWindow? { + if let key = NSApp.keyWindow { return key } + if let visible = NSApp.windows.first(where: { $0.isVisible }) { return visible } + if let any = NSApp.windows.first { + any.makeKeyAndOrderFront(nil) + return any + } + return WindowFactory.makeMainWindow(rootView: OraRoot()) + } + + func handleIncomingURLs(_ urls: [URL]) { + let window = getWindow()! + for url in urls { + let userInfo: [AnyHashable: Any] = ["url": url] + DispatchQueue.main.async { + NotificationCenter.default.post(name: .openURL, object: window, userInfo: userInfo) + } + } + } } extension Notification.Name {} @@ -18,7 +42,6 @@ func deleteSwiftDataStore(_ loc: String) { let storeURL = URL.applicationSupportDirectory.appending(path: loc) let shmURL = storeURL.appendingPathExtension("-shm") let walURL = storeURL.appendingPathExtension("-wal") - try? fileManager.removeItem(at: storeURL) try? fileManager.removeItem(at: shmURL) try? fileManager.removeItem(at: walURL) @@ -29,38 +52,56 @@ class AppState: ObservableObject { @Published var launcherSearchText: String = "" @Published var showFinderIn: UUID? @Published var isFloatingTabSwitchVisible: Bool = false - @Published var isToolbarHidden: Bool = false - @Published var showFullURL: Bool = (UserDefaults.standard.object(forKey: "showFullURL") as? Bool) ?? true { - didSet { UserDefaults.standard.set(showFullURL, forKey: "showFullURL") } - } + @Published var isFullscreen: Bool = false } @main struct OraApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + // Shared model container that uses the same configuration as the main browser + private let sharedModelContainer: ModelContainer? = + try? ModelConfiguration.createOraContainer(isPrivate: false) + var body: some Scene { WindowGroup(id: "normal") { OraRoot() .frame(minWidth: 500, minHeight: 360) + .environmentObject(DefaultBrowserManager.shared) } .defaultSize(width: 1440, height: 900) .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) + .handlesExternalEvents(matching: []) WindowGroup("Private", id: "private") { OraRoot(isPrivate: true) .frame(minWidth: 500, minHeight: 360) + .environmentObject(DefaultBrowserManager.shared) } .defaultSize(width: 1440, height: 900) .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) + .handlesExternalEvents(matching: []) Settings { - SettingsContentView() - .environmentObject(AppearanceManager.shared) - .environmentObject(UpdateService.shared) - .withTheme() - }.commands { OraCommands() } + if let sharedModelContainer { + SettingsContentView() + .environmentObject(AppearanceManager.shared) + .environmentObject(UpdateService.shared) + .environmentObject(DefaultBrowserManager.shared) + .withTheme() + .modelContainer(sharedModelContainer) + } else { + // Fallback UI when SwiftData is completely broken + VStack { + Text("Settings Unavailable") + .font(.title) + } + .padding() + .frame(width: 400, height: 300) + } + } + .commands { OraCommands() } } } diff --git a/project.yml b/project.yml index b0c8058a..8b00de13 100644 --- a/project.yml +++ b/project.yml @@ -11,7 +11,7 @@ targets: ora: type: application platform: "macOS" - deploymentTarget: "15.0" + deploymentTarget: "15.4" sources: - path: ora excludes: @@ -49,6 +49,33 @@ targets: SUFeedURL: "https://the-ora.github.io/browser/appcast.xml" SUPublicEDKey: "Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI=" SUEnableAutomaticChecks: YES + CFBundleURLTypes: + - CFBundleURLName: Ora Browser + CFBundleTypeRole: Editor + LSHandlerRank: Owner + CFBundleURLSchemes: + - http + - https + CFBundleDocumentTypes: + - CFBundleTypeName: Web URL + CFBundleTypeRole: Viewer + LSHandlerRank: Owner + LSItemContentTypes: + - public.url + - CFBundleTypeName: HTML document + CFBundleTypeRole: Viewer + LSHandlerRank: Default + LSItemContentTypes: + - public.html + - CFBundleTypeName: XHTML document + CFBundleTypeRole: Viewer + LSHandlerRank: Default + LSItemContentTypes: + - public.xhtml + NSUserActivityTypes: + - NSUserActivityTypeBrowsingWeb + LSMinimumSystemVersion: "13.0" + LSApplicationCategoryType: public.app-category.web-browser entitlements: path: ora/ora.entitlements @@ -69,8 +96,8 @@ targets: base: SWIFT_VERSION: 5.9 CODE_SIGN_STYLE: Automatic - MARKETING_VERSION: 0.2.3 - CURRENT_PROJECT_VERSION: 91 + MARKETING_VERSION: 0.2.5 + CURRENT_PROJECT_VERSION: 93 PRODUCT_NAME: Ora PRODUCT_BUNDLE_IDENTIFIER: com.orabrowser.app GENERATE_INFOPLIST_FILE: YES