├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml └── workflows │ └── build.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .ncurc.json ├── .nvmrc ├── .prettierignore ├── .vscode └── settings.json ├── @types ├── globals.d.ts └── jest-dom.d.ts ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── __fixtures__ └── key-layout.ts ├── __mocks__ ├── electron.cjs ├── file-mock.cjs └── style-mock.cjs ├── commitlint.config.cjs ├── designs ├── icon.afdesign ├── screenshot.pxd │ ├── QuickLook │ │ ├── Icon.tiff │ │ └── Thumbnail.tiff │ ├── data │ │ ├── 057D81EC-5A9B-4C48-B606-DFA084D113C9 │ │ ├── 3C2A725B-04BF-4AAE-A130-9AC91874C622 │ │ ├── 47CD18E3-C0EC-4861-A42F-4DF65E4BBEAB │ │ ├── 77D12897-ECC9-4DB9-A311-EF7C55A79565 │ │ ├── B3E17506-FBAF-44B5-8650-DBA0C484283D │ │ └── BD2F3A1C-C037-4E22-9E56-EDD487E04965 │ └── metadata.info ├── tray_icon.afdesign └── tray_iconHighlight.afdesign ├── docs ├── CNAME ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── html_code.html ├── icon_squooshed.png ├── index.html ├── mstile-150x150.png ├── safari-pinned-tab.svg ├── screenshot.jpg ├── site.webmanifest ├── style.css └── yellow-button.png ├── eslint.config.mjs ├── forge.config.js ├── guide ├── creating-app-icon.md ├── privacy.md ├── publishing.md ├── setting-up-for-development.md └── supporting-a-browser-or-app.md ├── jest-setup-files-after-environment.ts ├── jest.config.ts ├── lint-staged.config.mjs ├── package-lock.json ├── package.json ├── plist ├── Info.plist └── entitlements.mac.plist ├── postcss.config.cjs ├── prettier.config.cjs ├── scripts ├── list-installed-apps.ts └── png2icns.sh ├── src ├── config │ ├── apps.test.ts │ ├── apps.ts │ └── constants.ts ├── main │ ├── database.ts │ ├── main.ts │ ├── state │ │ ├── actions.ts │ │ ├── middleware.action-hub.ts │ │ ├── middleware.bus.ts │ │ └── store.ts │ ├── tray.ts │ ├── utils │ │ ├── copy-url-to-clipboard.test.ts │ │ ├── copy-url-to-clipboard.ts │ │ ├── get-app-icons.ts │ │ ├── get-installed-app-names.ts │ │ ├── get-update-url.ts │ │ ├── init-update-checker.ts │ │ ├── is-update-available.ts │ │ ├── open-app.ts │ │ └── remove-windows-from-memory.ts │ └── windows.ts ├── renderers │ ├── picker │ │ ├── components │ │ │ ├── _bootstrap.tsx │ │ │ ├── atoms │ │ │ │ ├── app-logo.tsx │ │ │ │ └── kbd.tsx │ │ │ ├── hooks │ │ │ │ └── use-keyboard-events.ts │ │ │ ├── layout.tsx │ │ │ └── organisms │ │ │ │ ├── apps.test.tsx │ │ │ │ ├── support-message.tsx │ │ │ │ ├── update-bar.tsx │ │ │ │ ├── url-bar.test.tsx │ │ │ │ └── url-bar.tsx │ │ ├── index.html │ │ ├── index.tsx │ │ ├── refs.ts │ │ └── state │ │ │ ├── actions.ts │ │ │ ├── middleware.ts │ │ │ └── store.ts │ ├── prefs │ │ ├── components │ │ │ ├── _bootstrap.tsx │ │ │ ├── layout.tsx │ │ │ ├── molecules │ │ │ │ └── pane.tsx │ │ │ └── organisms │ │ │ │ ├── header-bar.tsx │ │ │ │ ├── pane-about.tsx │ │ │ │ ├── pane-apps.tsx │ │ │ │ └── pane-general.tsx │ │ ├── index.html │ │ ├── index.tsx │ │ └── state │ │ │ ├── actions.ts │ │ │ ├── middleware.ts │ │ │ └── store.ts │ └── shared │ │ ├── components │ │ └── atoms │ │ │ ├── button.tsx │ │ │ ├── input.tsx │ │ │ └── spinner.tsx │ │ ├── custom.window.ts │ │ ├── index.css │ │ ├── preload.ts │ │ ├── state │ │ ├── actions.ts │ │ ├── hooks.ts │ │ └── middleware.bus.ts │ │ └── utils │ │ └── get-key-layout-map.ts └── shared │ ├── state │ ├── channels.ts │ ├── middleware.channel-injector.ts │ ├── middleware.log.ts │ ├── model.ts │ ├── reducer.data.ts │ ├── reducer.root.ts │ └── reducer.storage.ts │ ├── static │ └── icon │ │ ├── icon.icns │ │ ├── icon.png │ │ ├── tray_iconHighlight.png │ │ ├── tray_iconHighlight@2x.png │ │ ├── tray_iconTemplate.png │ │ └── tray_iconTemplate@2x.png │ └── utils │ ├── action-logger.ts │ ├── action-namespacer.ts │ ├── add-channel-to-action.ts │ ├── get-keys.ts │ └── logger.ts ├── tailwind.config.cjs ├── tsconfig.json ├── vite.main.config.ts ├── vite.preload.config.ts ├── vite.renderer.picker.config.ts └── vite.renderer.prefs.config.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug to help us fix any issues happening with Browserosaurus 3 | labels: [bug] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Is there an existing issue for this? 8 | description: 9 | Please search to see if an issue already exists for the bug you 10 | encountered. 11 | options: 12 | - label: I have searched the existing issues 13 | required: true 14 | - label: 15 | I have searched the [discussions 16 | forum](https://github.com/will-stone/browserosaurus/discussions) 17 | required: true 18 | - label: 19 | I understand this form is for reporting bugs, and not for requesting 20 | [support for new browsers or 21 | apps](https://github.com/will-stone/browserosaurus/blob/main/guide/supporting-a-browser-or-app.md). 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Current Behaviour 26 | description: A concise description of what you're experiencing. 27 | validations: 28 | required: true 29 | - type: textarea 30 | attributes: 31 | label: Expected Behaviour 32 | description: A concise description of what you expected to happen. 33 | validations: 34 | required: true 35 | - type: textarea 36 | attributes: 37 | label: Steps To Reproduce 38 | description: Steps to reproduce the behaviour. 39 | placeholder: | 40 | 1. Go to '...' 41 | 2. Click on '....' 42 | 3. Scroll down to '....' 43 | 4. See error 44 | validations: 45 | required: true 46 | - type: input 47 | attributes: 48 | label: Browserosaurus version 49 | description: This can be found in Preferences > About 50 | placeholder: 'e.g. 15.1.3' 51 | validations: 52 | required: true 53 | - type: input 54 | attributes: 55 | label: macOS version 56 | placeholder: 'e.g. 11.6' 57 | validations: 58 | required: true 59 | - type: dropdown 60 | attributes: 61 | label: CPU Architecture 62 | options: 63 | - ARM 64 | - Intel 65 | validations: 66 | required: true 67 | - type: textarea 68 | attributes: 69 | label: Anything else? 70 | description: | 71 | Anything that will give us more context about the issue you are encountering. 72 | 73 | Tip: You can attach screenshots or screen recordings by clicking this area to highlight it and then dragging files in. 74 | validations: 75 | required: false 76 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Get help in GitHub Discussions 5 | url: https://github.com/will-stone/browserosaurus/discussions 6 | about: 7 | Have a question? Not sure if your issue affects everyone reproducibly? The 8 | quickest way to get help is on Browserosaurus's GitHub Discussions! 9 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: [pull_request, push] 2 | 3 | jobs: 4 | build: 5 | strategy: 6 | matrix: 7 | os: [macos-13] 8 | runs-on: ${{ matrix.os }} 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Install Node.js 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 22.10.x 16 | cache: 'npm' 17 | - run: npm ci --audit false 18 | - run: npm run lint 19 | - run: npm run typecheck 20 | - run: npm run test 21 | - run: npm run package 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | out 4 | .env 5 | .idea 6 | .vite 7 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no lint-staged 2 | -------------------------------------------------------------------------------- /.ncurc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reject": ["lowdb", "@types/node"] 3 | } 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | docs/style.css -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "workbench.colorCustomizations": { 4 | "titleBar.activeForeground": "#037afa", 5 | "titleBar.inactiveForeground": "#117df099" 6 | }, 7 | "typescript.preferences.importModuleSpecifier": "relative", 8 | "cSpell.words": [ 9 | "asar", 10 | "fullscreenable", 11 | "icns", 12 | "maximizable", 13 | "minimizable", 14 | "Namespacer", 15 | "onlyin", 16 | "relocator", 17 | "timfish" 18 | ], 19 | "eslint.options": { 20 | "overrideConfigFile": "eslint.config.mjs" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /@types/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' 2 | declare module '*.svg' 3 | declare module '*.css' 4 | -------------------------------------------------------------------------------- /@types/jest-dom.d.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Browserosaurus is now considered _Public Feature Complete_, however I'm always 2 | open to ideas. 3 | 4 | - [New apps/browsers](guide/supporting-a-browser-or-app.md) 5 | - [Bugs](https://github.com/will-stone/browserosaurus/issues) 6 | - [Feature requests](https://github.com/will-stone/browserosaurus/discussions/categories/ideas) 7 | - [Help](https://github.com/will-stone/browserosaurus/discussions/categories/q-a) 8 | 9 | If you request a new feature that I do not consider a good fit for the project, 10 | please fork the project, that is what Open Source is all about. Make something 11 | for your own personality, strip out the stuff you don’t like, have fun with your 12 | own brand. I once suggested the name Browseratops to someone who wanted to make 13 | a Linux port… they didn’t, by the way, in case you fancied trying that too. Be 14 | sure to tell me how you get on; I'm always interested to hear about new 15 | projects. 16 | 17 | 😘 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. Everyone is 5 | permitted to copy and distribute verbatim copies of this license document, but 6 | changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for software and 11 | other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed to take 14 | away your freedom to share and change the works. By contrast, the GNU General 15 | Public License is intended to guarantee your freedom to share and change all 16 | versions of a program--to make sure it remains free software for all its users. 17 | We, the Free Software Foundation, use the GNU General Public License for most of 18 | our software; it applies also to any other work released this way by its 19 | authors. You can apply it to your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not price. Our 22 | General Public Licenses are designed to make sure that you have the freedom to 23 | distribute copies of free software (and charge for them if you wish), that you 24 | receive source code or can get it if you want it, that you can change the 25 | software or use pieces of it in new free programs, and that you know you can do 26 | these things. 27 | 28 | To protect your rights, we need to prevent others from denying you these rights 29 | or asking you to surrender the rights. Therefore, you have certain 30 | responsibilities if you distribute copies of the software, or if you modify it: 31 | responsibilities to respect the freedom of others. 32 | 33 | For example, if you distribute copies of such a program, whether gratis or for a 34 | fee, you must pass on to the recipients the same freedoms that you received. You 35 | must make sure that they, too, receive or can get the source code. And you must 36 | show them these terms so they know their rights. 37 | 38 | Developers that use the GNU GPL protect your rights with two steps: (1) assert 39 | copyright on the software, and (2) offer you this License giving you legal 40 | permission to copy, distribute and/or modify it. 41 | 42 | For the developers' and authors' protection, the GPL clearly explains that there 43 | is no warranty for this free software. For both users' and authors' sake, the 44 | GPL requires that modified versions be marked as changed, so that their problems 45 | will not be attributed erroneously to authors of previous versions. 46 | 47 | Some devices are designed to deny users access to install or run modified 48 | versions of the software inside them, although the manufacturer can do so. This 49 | is fundamentally incompatible with the aim of protecting users' freedom to 50 | change the software. The systematic pattern of such abuse occurs in the area of 51 | products for individuals to use, which is precisely where it is most 52 | unacceptable. Therefore, we have designed this version of the GPL to prohibit 53 | the practice for those products. If such problems arise substantially in other 54 | domains, we stand ready to extend this provision to those domains in future 55 | versions of the GPL, as needed to protect the freedom of users. 56 | 57 | Finally, every program is threatened constantly by software patents. States 58 | should not allow patents to restrict development and use of software on 59 | general-purpose computers, but in those that do, we wish to avoid the special 60 | danger that patents applied to a free program could make it effectively 61 | proprietary. To prevent this, the GPL assures that patents cannot be used to 62 | render the program non-free. 63 | 64 | The precise terms and conditions for copying, distribution and modification 65 | follow. 66 | 67 | TERMS AND CONDITIONS 68 | 69 | 0. Definitions. 70 | 71 | "This License" refers to version 3 of the GNU General Public License. 72 | 73 | "Copyright" also means copyright-like laws that apply to other kinds of works, 74 | such as semiconductor masks. 75 | 76 | "The Program" refers to any copyrightable work licensed under this License. Each 77 | licensee is addressed as "you". "Licensees" and "recipients" may be individuals 78 | or organizations. 79 | 80 | To "modify" a work means to copy from or adapt all or part of the work in a 81 | fashion requiring copyright permission, other than the making of an exact copy. 82 | The resulting work is called a "modified version" of the earlier work or a work 83 | "based on" the earlier work. 84 | 85 | A "covered work" means either the unmodified Program or a work based on the 86 | Program. 87 | 88 | To "propagate" a work means to do anything with it that, without permission, 89 | would make you directly or secondarily liable for infringement under applicable 90 | copyright law, except executing it on a computer or modifying a private copy. 91 | Propagation includes copying, distribution (with or without modification), 92 | making available to the public, and in some countries other activities as well. 93 | 94 | To "convey" a work means any kind of propagation that enables other parties to 95 | make or receive copies. Mere interaction with a user through a computer network, 96 | with no transfer of a copy, is not conveying. 97 | 98 | An interactive user interface displays "Appropriate Legal Notices" to the extent 99 | that it includes a convenient and prominently visible feature that (1) displays 100 | an appropriate copyright notice, and (2) tells the user that there is no 101 | warranty for the work (except to the extent that warranties are provided), that 102 | licensees may convey the work under this License, and how to view a copy of this 103 | License. If the interface presents a list of user commands or options, such as a 104 | menu, a prominent item in the list meets this criterion. 105 | 106 | 1. Source Code. 107 | 108 | The "source code" for a work means the preferred form of the work for making 109 | modifications to it. "Object code" means any non-source form of a work. 110 | 111 | A "Standard Interface" means an interface that either is an official standard 112 | defined by a recognized standards body, or, in the case of interfaces specified 113 | for a particular programming language, one that is widely used among developers 114 | working in that language. 115 | 116 | The "System Libraries" of an executable work include anything, other than the 117 | work as a whole, that (a) is included in the normal form of packaging a Major 118 | Component, but which is not part of that Major Component, and (b) serves only to 119 | enable use of the work with that Major Component, or to implement a Standard 120 | Interface for which an implementation is available to the public in source code 121 | form. A "Major Component", in this context, means a major essential component 122 | (kernel, window system, and so on) of the specific operating system (if any) on 123 | which the executable work runs, or a compiler used to produce the work, or an 124 | object code interpreter used to run it. 125 | 126 | The "Corresponding Source" for a work in object code form means all the source 127 | code needed to generate, install, and (for an executable work) run the object 128 | code and to modify the work, including scripts to control those activities. 129 | However, it does not include the work's System Libraries, or general-purpose 130 | tools or generally available free programs which are used unmodified in 131 | performing those activities but which are not part of the work. For example, 132 | Corresponding Source includes interface definition files associated with source 133 | files for the work, and the source code for shared libraries and dynamically 134 | linked subprograms that the work is specifically designed to require, such as by 135 | intimate data communication or control flow between those subprograms and other 136 | parts of the work. 137 | 138 | The Corresponding Source need not include anything that users can regenerate 139 | automatically from other parts of the Corresponding Source. 140 | 141 | The Corresponding Source for a work in source code form is that same work. 142 | 143 | 2. Basic Permissions. 144 | 145 | All rights granted under this License are granted for the term of copyright on 146 | the Program, and are irrevocable provided the stated conditions are met. This 147 | License explicitly affirms your unlimited permission to run the unmodified 148 | Program. The output from running a covered work is covered by this License only 149 | if the output, given its content, constitutes a covered work. This License 150 | acknowledges your rights of fair use or other equivalent, as provided by 151 | copyright law. 152 | 153 | You may make, run and propagate covered works that you do not convey, without 154 | conditions so long as your license otherwise remains in force. You may convey 155 | covered works to others for the sole purpose of having them make modifications 156 | exclusively for you, or provide you with facilities for running those works, 157 | provided that you comply with the terms of this License in conveying all 158 | material for which you do not control copyright. Those thus making or running 159 | the covered works for you must do so exclusively on your behalf, under your 160 | direction and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under the conditions 164 | stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 165 | 166 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 167 | 168 | No covered work shall be deemed part of an effective technological measure under 169 | any applicable law fulfilling obligations under article 11 of the WIPO copyright 170 | treaty adopted on 20 December 1996, or similar laws prohibiting or restricting 171 | circumvention of such measures. 172 | 173 | When you convey a covered work, you waive any legal power to forbid 174 | circumvention of technological measures to the extent such circumvention is 175 | effected by exercising rights under this License with respect to the covered 176 | work, and you disclaim any intention to limit operation or modification of the 177 | work as a means of enforcing, against the work's users, your or third parties' 178 | legal rights to forbid circumvention of technological measures. 179 | 180 | 4. Conveying Verbatim Copies. 181 | 182 | You may convey verbatim copies of the Program's source code as you receive it, 183 | in any medium, provided that you conspicuously and appropriately publish on each 184 | copy an appropriate copyright notice; keep intact all notices stating that this 185 | License and any non-permissive terms added in accord with section 7 apply to the 186 | code; keep intact all notices of the absence of any warranty; and give all 187 | recipients a copy of this License along with the Program. 188 | 189 | You may charge any price or no price for each copy that you convey, and you may 190 | offer support or warranty protection for a fee. 191 | 192 | 5. Conveying Modified Source Versions. 193 | 194 | You may convey a work based on the Program, or the modifications to produce it 195 | from the Program, in the form of source code under the terms of section 4, 196 | provided that you also meet all of these conditions: 197 | 198 | a) The work must carry prominent notices stating that you modified 199 | it, and giving a relevant date. 200 | 201 | b) The work must carry prominent notices stating that it is 202 | released under this License and any conditions added under section 203 | 7. This requirement modifies the requirement in section 4 to 204 | "keep intact all notices". 205 | 206 | c) You must license the entire work, as a whole, under this 207 | License to anyone who comes into possession of a copy. This 208 | License will therefore apply, along with any applicable section 7 209 | additional terms, to the whole of the work, and all its parts, 210 | regardless of how they are packaged. This License gives no 211 | permission to license the work in any other way, but it does not 212 | invalidate such permission if you have separately received it. 213 | 214 | d) If the work has interactive user interfaces, each must display 215 | Appropriate Legal Notices; however, if the Program has interactive 216 | interfaces that do not display Appropriate Legal Notices, your 217 | work need not make them do so. 218 | 219 | A compilation of a covered work with other separate and independent works, which 220 | are not by their nature extensions of the covered work, and which are not 221 | combined with it such as to form a larger program, in or on a volume of a 222 | storage or distribution medium, is called an "aggregate" if the compilation and 223 | its resulting copyright are not used to limit the access or legal rights of the 224 | compilation's users beyond what the individual works permit. Inclusion of a 225 | covered work in an aggregate does not cause this License to apply to the other 226 | parts of the aggregate. 227 | 228 | 6. Conveying Non-Source Forms. 229 | 230 | You may convey a covered work in object code form under the terms of sections 4 231 | and 5, provided that you also convey the machine-readable Corresponding Source 232 | under the terms of this License, in one of these ways: 233 | 234 | a) Convey the object code in, or embodied in, a physical product 235 | (including a physical distribution medium), accompanied by the 236 | Corresponding Source fixed on a durable physical medium 237 | customarily used for software interchange. 238 | 239 | b) Convey the object code in, or embodied in, a physical product 240 | (including a physical distribution medium), accompanied by a 241 | written offer, valid for at least three years and valid for as 242 | long as you offer spare parts or customer support for that product 243 | model, to give anyone who possesses the object code either (1) a 244 | copy of the Corresponding Source for all the software in the 245 | product that is covered by this License, on a durable physical 246 | medium customarily used for software interchange, for a price no 247 | more than your reasonable cost of physically performing this 248 | conveying of source, or (2) access to copy the 249 | Corresponding Source from a network server at no charge. 250 | 251 | c) Convey individual copies of the object code with a copy of the 252 | written offer to provide the Corresponding Source. This 253 | alternative is allowed only occasionally and noncommercially, and 254 | only if you received the object code with such an offer, in accord 255 | with subsection 6b. 256 | 257 | d) Convey the object code by offering access from a designated 258 | place (gratis or for a charge), and offer equivalent access to the 259 | Corresponding Source in the same way through the same place at no 260 | further charge. You need not require recipients to copy the 261 | Corresponding Source along with the object code. If the place to 262 | copy the object code is a network server, the Corresponding Source 263 | may be on a different server (operated by you or a third party) 264 | that supports equivalent copying facilities, provided you maintain 265 | clear directions next to the object code saying where to find the 266 | Corresponding Source. Regardless of what server hosts the 267 | Corresponding Source, you remain obligated to ensure that it is 268 | available for as long as needed to satisfy these requirements. 269 | 270 | e) Convey the object code using peer-to-peer transmission, provided 271 | you inform other peers where the object code and Corresponding 272 | Source of the work are being offered to the general public at no 273 | charge under subsection 6d. 274 | 275 | A separable portion of the object code, whose source code is excluded from the 276 | Corresponding Source as a System Library, need not be included in conveying the 277 | object code work. 278 | 279 | A "User Product" is either (1) a "consumer product", which means any tangible 280 | personal property which is normally used for personal, family, or household 281 | purposes, or (2) anything designed or sold for incorporation into a dwelling. In 282 | determining whether a product is a consumer product, doubtful cases shall be 283 | resolved in favor of coverage. For a particular product received by a particular 284 | user, "normally used" refers to a typical or common use of that class of 285 | product, regardless of the status of the particular user or of the way in which 286 | the particular user actually uses, or expects or is expected to use, the 287 | product. A product is a consumer product regardless of whether the product has 288 | substantial commercial, industrial or non-consumer uses, unless such uses 289 | represent the only significant mode of use of the product. 290 | 291 | "Installation Information" for a User Product means any methods, procedures, 292 | authorization keys, or other information required to install and execute 293 | modified versions of a covered work in that User Product from a modified version 294 | of its Corresponding Source. The information must suffice to ensure that the 295 | continued functioning of the modified object code is in no case prevented or 296 | interfered with solely because modification has been made. 297 | 298 | If you convey an object code work under this section in, or with, or 299 | specifically for use in, a User Product, and the conveying occurs as part of a 300 | transaction in which the right of possession and use of the User Product is 301 | transferred to the recipient in perpetuity or for a fixed term (regardless of 302 | how the transaction is characterized), the Corresponding Source conveyed under 303 | this section must be accompanied by the Installation Information. But this 304 | requirement does not apply if neither you nor any third party retains the 305 | ability to install modified object code on the User Product (for example, the 306 | work has been installed in ROM). 307 | 308 | The requirement to provide Installation Information does not include a 309 | requirement to continue to provide support service, warranty, or updates for a 310 | work that has been modified or installed by the recipient, or for the User 311 | Product in which it has been modified or installed. Access to a network may be 312 | denied when the modification itself materially and adversely affects the 313 | operation of the network or violates the rules and protocols for communication 314 | across the network. 315 | 316 | Corresponding Source conveyed, and Installation Information provided, in accord 317 | with this section must be in a format that is publicly documented (and with an 318 | implementation available to the public in source code form), and must require no 319 | special password or key for unpacking, reading or copying. 320 | 321 | 7. Additional Terms. 322 | 323 | "Additional permissions" are terms that supplement the terms of this License by 324 | making exceptions from one or more of its conditions. Additional permissions 325 | that are applicable to the entire Program shall be treated as though they were 326 | included in this License, to the extent that they are valid under applicable 327 | law. If additional permissions apply only to part of the Program, that part may 328 | be used separately under those permissions, but the entire Program remains 329 | governed by this License without regard to the additional permissions. 330 | 331 | When you convey a copy of a covered work, you may at your option remove any 332 | additional permissions from that copy, or from any part of it. (Additional 333 | permissions may be written to require their own removal in certain cases when 334 | you modify the work.) You may place additional permissions on material, added by 335 | you to a covered work, for which you have or can give appropriate copyright 336 | permission. 337 | 338 | Notwithstanding any other provision of this License, for material you add to a 339 | covered work, you may (if authorized by the copyright holders of that material) 340 | supplement the terms of this License with terms: 341 | 342 | a) Disclaiming warranty or limiting liability differently from the 343 | terms of sections 15 and 16 of this License; or 344 | 345 | b) Requiring preservation of specified reasonable legal notices or 346 | author attributions in that material or in the Appropriate Legal 347 | Notices displayed by works containing it; or 348 | 349 | c) Prohibiting misrepresentation of the origin of that material, or 350 | requiring that modified versions of such material be marked in 351 | reasonable ways as different from the original version; or 352 | 353 | d) Limiting the use for publicity purposes of names of licensors or 354 | authors of the material; or 355 | 356 | e) Declining to grant rights under trademark law for use of some 357 | trade names, trademarks, or service marks; or 358 | 359 | f) Requiring indemnification of licensors and authors of that 360 | material by anyone who conveys the material (or modified versions of 361 | it) with contractual assumptions of liability to the recipient, for 362 | any liability that these contractual assumptions directly impose on 363 | those licensors and authors. 364 | 365 | All other non-permissive additional terms are considered "further restrictions" 366 | within the meaning of section 10. If the Program as you received it, or any part 367 | of it, contains a notice stating that it is governed by this License along with 368 | a term that is a further restriction, you may remove that term. If a license 369 | document contains a further restriction but permits relicensing or conveying 370 | under this License, you may add to a covered work material governed by the terms 371 | of that license document, provided that the further restriction does not survive 372 | such relicensing or conveying. 373 | 374 | If you add terms to a covered work in accord with this section, you must place, 375 | in the relevant source files, a statement of the additional terms that apply to 376 | those files, or a notice indicating where to find the applicable terms. 377 | 378 | Additional terms, permissive or non-permissive, may be stated in the form of a 379 | separately written license, or stated as exceptions; the above requirements 380 | apply either way. 381 | 382 | 8. Termination. 383 | 384 | You may not propagate or modify a covered work except as expressly provided 385 | under this License. Any attempt otherwise to propagate or modify it is void, and 386 | will automatically terminate your rights under this License (including any 387 | patent licenses granted under the third paragraph of section 11). 388 | 389 | However, if you cease all violation of this License, then your license from a 390 | particular copyright holder is reinstated (a) provisionally, unless and until 391 | the copyright holder explicitly and finally terminates your license, and (b) 392 | permanently, if the copyright holder fails to notify you of the violation by 393 | some reasonable means prior to 60 days after the cessation. 394 | 395 | Moreover, your license from a particular copyright holder is reinstated 396 | permanently if the copyright holder notifies you of the violation by some 397 | reasonable means, this is the first time you have received notice of violation 398 | of this License (for any work) from that copyright holder, and you cure the 399 | violation prior to 30 days after your receipt of the notice. 400 | 401 | Termination of your rights under this section does not terminate the licenses of 402 | parties who have received copies or rights from you under this License. If your 403 | rights have been terminated and not permanently reinstated, you do not qualify 404 | to receive new licenses for the same material under section 10. 405 | 406 | 9. Acceptance Not Required for Having Copies. 407 | 408 | You are not required to accept this License in order to receive or run a copy of 409 | the Program. Ancillary propagation of a covered work occurring solely as a 410 | consequence of using peer-to-peer transmission to receive a copy likewise does 411 | not require acceptance. However, nothing other than this License grants you 412 | permission to propagate or modify any covered work. These actions infringe 413 | copyright if you do not accept this License. Therefore, by modifying or 414 | propagating a covered work, you indicate your acceptance of this License to do 415 | so. 416 | 417 | 10. Automatic Licensing of Downstream Recipients. 418 | 419 | Each time you convey a covered work, the recipient automatically receives a 420 | license from the original licensors, to run, modify and propagate that work, 421 | subject to this License. You are not responsible for enforcing compliance by 422 | third parties with this License. 423 | 424 | An "entity transaction" is a transaction transferring control of an 425 | organization, or substantially all assets of one, or subdividing an 426 | organization, or merging organizations. If propagation of a covered work results 427 | from an entity transaction, each party to that transaction who receives a copy 428 | of the work also receives whatever licenses to the work the party's predecessor 429 | in interest had or could give under the previous paragraph, plus a right to 430 | possession of the Corresponding Source of the work from the predecessor in 431 | interest, if the predecessor has it or can get it with reasonable efforts. 432 | 433 | You may not impose any further restrictions on the exercise of the rights 434 | granted or affirmed under this License. For example, you may not impose a 435 | license fee, royalty, or other charge for exercise of rights granted under this 436 | License, and you may not initiate litigation (including a cross-claim or 437 | counterclaim in a lawsuit) alleging that any patent claim is infringed by 438 | making, using, selling, offering for sale, or importing the Program or any 439 | portion of it. 440 | 441 | 11. Patents. 442 | 443 | A "contributor" is a copyright holder who authorizes use under this License of 444 | the Program or a work on which the Program is based. The work thus licensed is 445 | called the contributor's "contributor version". 446 | 447 | A contributor's "essential patent claims" are all patent claims owned or 448 | controlled by the contributor, whether already acquired or hereafter acquired, 449 | that would be infringed by some manner, permitted by this License, of making, 450 | using, or selling its contributor version, but do not include claims that would 451 | be infringed only as a consequence of further modification of the contributor 452 | version. For purposes of this definition, "control" includes the right to grant 453 | patent sublicenses in a manner consistent with the requirements of this License. 454 | 455 | Each contributor grants you a non-exclusive, worldwide, royalty-free patent 456 | license under the contributor's essential patent claims, to make, use, sell, 457 | offer for sale, import and otherwise run, modify and propagate the contents of 458 | its contributor version. 459 | 460 | In the following three paragraphs, a "patent license" is any express agreement 461 | or commitment, however denominated, not to enforce a patent (such as an express 462 | permission to practice a patent or covenant not to sue for patent infringement). 463 | To "grant" such a patent license to a party means to make such an agreement or 464 | commitment not to enforce a patent against the party. 465 | 466 | If you convey a covered work, knowingly relying on a patent license, and the 467 | Corresponding Source of the work is not available for anyone to copy, free of 468 | charge and under the terms of this License, through a publicly available network 469 | server or other readily accessible means, then you must either (1) cause the 470 | Corresponding Source to be so available, or (2) arrange to deprive yourself of 471 | the benefit of the patent license for this particular work, or (3) arrange, in a 472 | manner consistent with the requirements of this License, to extend the patent 473 | license to downstream recipients. "Knowingly relying" means you have actual 474 | knowledge that, but for the patent license, your conveying the covered work in a 475 | country, or your recipient's use of the covered work in a country, would 476 | infringe one or more identifiable patents in that country that you have reason 477 | to believe are valid. 478 | 479 | If, pursuant to or in connection with a single transaction or arrangement, you 480 | convey, or propagate by procuring conveyance of, a covered work, and grant a 481 | patent license to some of the parties receiving the covered work authorizing 482 | them to use, propagate, modify or convey a specific copy of the covered work, 483 | then the patent license you grant is automatically extended to all recipients of 484 | the covered work and works based on it. 485 | 486 | A patent license is "discriminatory" if it does not include within the scope of 487 | its coverage, prohibits the exercise of, or is conditioned on the non-exercise 488 | of one or more of the rights that are specifically granted under this License. 489 | You may not convey a covered work if you are a party to an arrangement with a 490 | third party that is in the business of distributing software, under which you 491 | make payment to the third party based on the extent of your activity of 492 | conveying the work, and under which the third party grants, to any of the 493 | parties who would receive the covered work from you, a discriminatory patent 494 | license (a) in connection with copies of the covered work conveyed by you (or 495 | copies made from those copies), or (b) primarily for and in connection with 496 | specific products or compilations that contain the covered work, unless you 497 | entered into that arrangement, or that patent license was granted, prior to 28 498 | March 2007. 499 | 500 | Nothing in this License shall be construed as excluding or limiting any implied 501 | license or other defenses to infringement that may otherwise be available to you 502 | under applicable patent law. 503 | 504 | 12. No Surrender of Others' Freedom. 505 | 506 | If conditions are imposed on you (whether by court order, agreement or 507 | otherwise) that contradict the conditions of this License, they do not excuse 508 | you from the conditions of this License. If you cannot convey a covered work so 509 | as to satisfy simultaneously your obligations under this License and any other 510 | pertinent obligations, then as a consequence you may not convey it at all. For 511 | example, if you agree to terms that obligate you to collect a royalty for 512 | further conveying from those to whom you convey the Program, the only way you 513 | could satisfy both those terms and this License would be to refrain entirely 514 | from conveying the Program. 515 | 516 | 13. Use with the GNU Affero General Public License. 517 | 518 | Notwithstanding any other provision of this License, you have permission to link 519 | or combine any covered work with a work licensed under version 3 of the GNU 520 | Affero General Public License into a single combined work, and to convey the 521 | resulting work. The terms of this License will continue to apply to the part 522 | which is the covered work, but the special requirements of the GNU Affero 523 | General Public License, section 13, concerning interaction through a network 524 | will apply to the combination as such. 525 | 526 | 14. Revised Versions of this License. 527 | 528 | The Free Software Foundation may publish revised and/or new versions of the GNU 529 | General Public License from time to time. Such new versions will be similar in 530 | spirit to the present version, but may differ in detail to address new problems 531 | or concerns. 532 | 533 | Each version is given a distinguishing version number. If the Program specifies 534 | that a certain numbered version of the GNU General Public License "or any later 535 | version" applies to it, you have the option of following the terms and 536 | conditions either of that numbered version or of any later version published by 537 | the Free Software Foundation. If the Program does not specify a version number 538 | of the GNU General Public License, you may choose any version ever published by 539 | the Free Software Foundation. 540 | 541 | If the Program specifies that a proxy can decide which future versions of the 542 | GNU General Public License can be used, that proxy's public statement of 543 | acceptance of a version permanently authorizes you to choose that version for 544 | the Program. 545 | 546 | Later license versions may give you additional or different permissions. 547 | However, no additional obligations are imposed on any author or copyright holder 548 | as a result of your choosing to follow a later version. 549 | 550 | 15. Disclaimer of Warranty. 551 | 552 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 553 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER 554 | PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER 555 | EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 556 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE 557 | QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 558 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 559 | 560 | 16. Limitation of Liability. 561 | 562 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY 563 | COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS 564 | PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, 565 | INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE 566 | THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED 567 | INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE 568 | PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY 569 | HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 570 | 571 | 17. Interpretation of Sections 15 and 16. 572 | 573 | If the disclaimer of warranty and limitation of liability provided above cannot 574 | be given local legal effect according to their terms, reviewing courts shall 575 | apply local law that most closely approximates an absolute waiver of all civil 576 | liability in connection with the Program, unless a warranty or assumption of 577 | liability accompanies a copy of the Program in return for a fee. 578 | 579 | END OF TERMS AND CONDITIONS 580 | 581 | How to Apply These Terms to Your New Programs 582 | 583 | If you develop a new program, and you want it to be of the greatest possible use 584 | to the public, the best way to achieve this is to make it free software which 585 | everyone can redistribute and change under these terms. 586 | 587 | To do so, attach the following notices to the program. It is safest to attach 588 | them to the start of each source file to most effectively state the exclusion of 589 | warranty; and each file should have at least the "copyright" line and a pointer 590 | to where the full notice is found. 591 | 592 | 593 | Copyright (C) 594 | 595 | This program is free software: you can redistribute it and/or modify 596 | it under the terms of the GNU General Public License as published by 597 | the Free Software Foundation, either version 3 of the License, or 598 | (at your option) any later version. 599 | 600 | This program is distributed in the hope that it will be useful, 601 | but WITHOUT ANY WARRANTY; without even the implied warranty of 602 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 603 | GNU General Public License for more details. 604 | 605 | You should have received a copy of the GNU General Public License 606 | along with this program. If not, see . 607 | 608 | Also add information on how to contact you by electronic and paper mail. 609 | 610 | If the program does terminal interaction, make it output a short notice like 611 | this when it starts in an interactive mode: 612 | 613 | Copyright (C) 614 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 615 | This is free software, and you are welcome to redistribute it 616 | under certain conditions; type `show c' for details. 617 | 618 | The hypothetical commands `show w' and `show c' should show the appropriate 619 | parts of the General Public License. Of course, your program's commands might be 620 | different; for a GUI interface, you would use an "about box". 621 | 622 | You should also get your employer (if you work as a programmer) or school, if 623 | any, to sign a "copyright disclaimer" for the program, if necessary. For more 624 | information on this, and how to apply and follow the GNU GPL, see 625 | . 626 | 627 | The GNU General Public License does not permit incorporating your program into 628 | proprietary programs. If your program is a subroutine library, you may consider 629 | it more useful to permit linking proprietary applications with the library. If 630 | this is what you want to do, use the GNU Lesser General Public License instead 631 | of this License. But first, please read 632 | . 633 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logo 2 | 3 | # Browserosaurus 4 | 5 | Browserosaurus is an open-source (GPLv3 license) browser prompter for macOS. It 6 | works by setting itself as the default browser, and any clicked links in non-browser 7 | apps are now sent to Browserosaurus where you are presented with a menu of all 8 | your installed browsers. You may now decide which app you’d like to continue 9 | opening the link with. 10 | 11 | screenshot 12 | 13 | ## Installation 14 | 15 | Download Browserosaurus from the 16 | [GitHub releases page](https://github.com/will-stone/browserosaurus/releases/latest). 17 | Select **x64** for Intel machines, or **arm64** for Apple Silicon (M1) machines. 18 | 19 | Or use [Homebrew](https://formulae.brew.sh/cask/browserosaurus#default). Thank 20 | you very much to [@i0ntempest](https://github.com/i0ntempest) and 21 | [@tk4k](https://github.com/tk4k) for keeping this cask updated 🙏 22 | 23 | ```sh 24 | brew install --cask browserosaurus 25 | ``` 26 | 27 | > 🚨 **Please note that Browserosaurus only officially supports the version of 28 | > macOS that I currently use, which you can assume to be the latest stable 29 | > version.** 30 | 31 | ## Help 32 | 33 | Found a bug? Please log an 34 | [issue](https://github.com/will-stone/browserosaurus/issues). For anything else, 35 | please see the documentation below or open a 36 | [discussion](https://github.com/will-stone/browserosaurus/discussions). 37 | 38 | ## Projects inspired by Browserosaurus 39 | 40 | Browserosaurus is primarily made for my needs and environment. Therefore, some 41 | feature requests do not make it into the main project, but that's the beauty of 42 | Open Source, you are free to copy the code and make your own tweaks (as long as 43 | it remains open-sourced, of course, please see the license 😉). Here are some 44 | forks of this project that you may like to consider: 45 | 46 | - [Browseratops](https://github.com/riotrah/browseratops) by 47 | [@riotrah](https://github.com/riotrah). Browserosaurus but for **Windows**! 48 | - [Browserino](https://github.com/AlexStrNik/Browserino) by 49 | [@alexstrnik](https://github.com/AlexStrNik). **Swift UI** port of 50 | Browserosaurus. 51 | 52 | > Please PR your own fork to this list. 53 | 54 | ## Documentation 55 | 56 | - [Changelog](https://github.com/will-stone/browserosaurus/releases) 57 | - [Help](https://github.com/will-stone/browserosaurus/discussions/categories/q-a) 58 | - [Supporting a new browser or app](guide/supporting-a-browser-or-app.md) 59 | - [Setting up for development](guide/setting-up-for-development.md) 60 | - [Privacy policy](guide/privacy.md) 61 | 62 | For the maintainer: 63 | 64 | - [Creating app icon](guide/creating-app-icon.md) 65 | - [Publishing](guide/publishing.md) 66 | -------------------------------------------------------------------------------- /__fixtures__/key-layout.ts: -------------------------------------------------------------------------------- 1 | export const keyLayout = [ 2 | ['Backquote', '`'], 3 | ['Backslash', '\\'], 4 | ['BracketLeft', '['], 5 | ['BracketRight', ']'], 6 | ['Comma', ','], 7 | ['Digit0', '0'], 8 | ['Digit1', '1'], 9 | ['Digit2', '2'], 10 | ['Digit3', '3'], 11 | ['Digit4', '4'], 12 | ['Digit5', '5'], 13 | ['Digit6', '6'], 14 | ['Digit7', '7'], 15 | ['Digit8', '8'], 16 | ['Digit9', '9'], 17 | ['Equal', '='], 18 | ['IntlBackslash', '§'], 19 | ['IntlRo', '`'], 20 | ['IntlYen', '§'], 21 | ['KeyA', 'a'], 22 | ['KeyB', 'b'], 23 | ['KeyC', 'c'], 24 | ['KeyD', 'd'], 25 | ['KeyE', 'e'], 26 | ['KeyF', 'f'], 27 | ['KeyG', 'g'], 28 | ['KeyH', 'h'], 29 | ['KeyI', 'i'], 30 | ['KeyJ', 'j'], 31 | ['KeyK', 'k'], 32 | ['KeyL', 'l'], 33 | ['KeyM', 'm'], 34 | ['KeyN', 'n'], 35 | ['KeyO', 'o'], 36 | ['KeyP', 'p'], 37 | ['KeyQ', 'q'], 38 | ['KeyR', 'r'], 39 | ['KeyS', 's'], 40 | ['KeyT', 't'], 41 | ['KeyU', 'u'], 42 | ['KeyV', 'v'], 43 | ['KeyW', 'w'], 44 | ['KeyX', 'x'], 45 | ['KeyY', 'y'], 46 | ['KeyZ', 'z'], 47 | ['Minus', '-'], 48 | ['Period', '.'], 49 | ['Quote', "'"], 50 | ['Semicolon', ';'], 51 | ['Slash', '/'], 52 | ] 53 | -------------------------------------------------------------------------------- /__mocks__/electron.cjs: -------------------------------------------------------------------------------- 1 | const EventTarget = require('node:events') 2 | const { act } = require('@testing-library/react') 3 | 4 | const eventEmitter = new EventTarget() 5 | 6 | let clipboard 7 | 8 | module.exports = { 9 | app: jest.fn(), 10 | BrowserWindow: function () { 11 | return { 12 | webContents: { 13 | send: jest.fn((eventName, payload) => 14 | act(() => { 15 | eventEmitter.emit(eventName, { 16 | ...payload, 17 | // web contents always sends an action from main 18 | meta: { ...payload.meta, channel: 'MAIN' }, 19 | }) 20 | }), 21 | ), 22 | }, 23 | } 24 | }, 25 | clipboard: { 26 | readText: () => clipboard, 27 | writeText: (string) => (clipboard = string), 28 | }, 29 | contextBridge: { 30 | exposeInMainWorld: jest.fn((apiKey, { send, receive }) => { 31 | window[apiKey] = { receive, send } 32 | }), 33 | }, 34 | dialog: jest.fn(), 35 | ipcRenderer: { 36 | on: jest.fn((eventName, function_) => 37 | eventEmitter.on(eventName, (payload) => function_(undefined, payload)), 38 | ), 39 | removeAllListeners: jest.fn((channel) => 40 | eventEmitter.removeAllListeners(channel), 41 | ), 42 | send: jest.fn(), 43 | }, 44 | match: jest.fn(), 45 | Notification: function () { 46 | return { 47 | show: jest.fn, 48 | } 49 | }, 50 | remote: { 51 | getCurrentWindow() { 52 | return { 53 | setIgnoreMouseEvents: jest.fn(), 54 | } 55 | }, 56 | }, 57 | require: jest.fn(), 58 | } 59 | -------------------------------------------------------------------------------- /__mocks__/file-mock.cjs: -------------------------------------------------------------------------------- 1 | // __mocks__/file-mock.js 2 | 3 | module.exports = 'test-file-stub' 4 | -------------------------------------------------------------------------------- /__mocks__/style-mock.cjs: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /designs/icon.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/icon.afdesign -------------------------------------------------------------------------------- /designs/screenshot.pxd/QuickLook/Icon.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/QuickLook/Icon.tiff -------------------------------------------------------------------------------- /designs/screenshot.pxd/QuickLook/Thumbnail.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/QuickLook/Thumbnail.tiff -------------------------------------------------------------------------------- /designs/screenshot.pxd/data/057D81EC-5A9B-4C48-B606-DFA084D113C9: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/data/057D81EC-5A9B-4C48-B606-DFA084D113C9 -------------------------------------------------------------------------------- /designs/screenshot.pxd/data/3C2A725B-04BF-4AAE-A130-9AC91874C622: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/data/3C2A725B-04BF-4AAE-A130-9AC91874C622 -------------------------------------------------------------------------------- /designs/screenshot.pxd/data/47CD18E3-C0EC-4861-A42F-4DF65E4BBEAB: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/data/47CD18E3-C0EC-4861-A42F-4DF65E4BBEAB -------------------------------------------------------------------------------- /designs/screenshot.pxd/data/77D12897-ECC9-4DB9-A311-EF7C55A79565: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/data/77D12897-ECC9-4DB9-A311-EF7C55A79565 -------------------------------------------------------------------------------- /designs/screenshot.pxd/data/B3E17506-FBAF-44B5-8650-DBA0C484283D: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/data/B3E17506-FBAF-44B5-8650-DBA0C484283D -------------------------------------------------------------------------------- /designs/screenshot.pxd/data/BD2F3A1C-C037-4E22-9E56-EDD487E04965: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/data/BD2F3A1C-C037-4E22-9E56-EDD487E04965 -------------------------------------------------------------------------------- /designs/screenshot.pxd/metadata.info: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/screenshot.pxd/metadata.info -------------------------------------------------------------------------------- /designs/tray_icon.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/tray_icon.afdesign -------------------------------------------------------------------------------- /designs/tray_iconHighlight.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/designs/tray_iconHighlight.afdesign -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | browserosaurus.com -------------------------------------------------------------------------------- /docs/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #071a25 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/favicon-16x16.png -------------------------------------------------------------------------------- /docs/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/favicon-32x32.png -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/favicon.ico -------------------------------------------------------------------------------- /docs/html_code.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/icon_squooshed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/icon_squooshed.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Browserosaurus: for macOS multi-browser users. 5 | 6 | 7 | 8 | 13 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 39 | 40 | 43 |
44 | 62 |
63 | 64 |
65 |
68 |
69 | 70 |
71 |
72 |
75 | For macOS multi-browser users. 76 |
77 |
80 | Select from any of your installed browsers when clicking a link in a 81 | non-browser app. 82 |
83 |
brew install --cask browserosaurus
88 | 99 |

100 | Select x64 for Intel machines, or arm64 for Apple Silicon (M1) 101 | machines. 102 |

103 |
104 |
105 |
106 | 107 |
108 |
109 | Created by 110 | Will Stone. 115 |
116 |
117 | 118 | 119 | -------------------------------------------------------------------------------- /docs/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/mstile-150x150.png -------------------------------------------------------------------------------- /docs/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/screenshot.jpg -------------------------------------------------------------------------------- /docs/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png?v=20", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png?v=20", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v3.3.5 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont;font-variation-settings:normal;line-height:1.5;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.mx-auto{margin-left:auto;margin-right:auto}.-ml-2{margin-left:-.5rem}.ml-auto{margin-left:auto}.mt-12{margin-top:3rem}.mt-16{margin-top:4rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.block{display:block}.flex{display:flex}.min-h-screen{min-height:100vh}.w-40{width:10rem}.w-full{width:100%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.select-none{-webkit-user-select:none;user-select:none}.select-all{-webkit-user-select:all;user-select:all}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.gap-3{gap:.75rem}.gap-5{gap:1.25rem}.gap-8{gap:2rem}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:.25rem}.rounded-md{border-radius:.375rem}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-zinc-50{--tw-bg-opacity:1;background-color:rgb(250 250 250/var(--tw-bg-opacity))}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.pl-3{padding-left:.75rem}.pr-4{padding-right:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem}.text-4xl{font-size:2.25rem}.text-lg{font-size:1.125rem}.text-xl{font-size:1.25rem}.font-bold{font-weight:700}.font-light{font-weight:300}.font-semibold{font-weight:600}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity))}.text-zinc-700{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity))}.text-zinc-800{--tw-text-opacity:1;color:rgb(39 39 42/var(--tw-text-opacity))}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-indigo-500\/25{--tw-shadow-color:rgba(99,102,241,.25);--tw-shadow:var(--tw-shadow-colored)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.before\:mr-2:before{content:var(--tw-content);margin-right:.5rem}.before\:content-\[\'\$\'\]:before{--tw-content:"$";content:var(--tw-content)}.hover\:bg-indigo-500\/90:hover{background-color:rgba(99,102,241,.9)}.hover\:text-indigo-500:hover{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.hover\:text-indigo-500\/90:hover{color:rgba(99,102,241,.9)}.active\:bg-indigo-600:active{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.active\:text-indigo-600:active{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.active\:shadow-sm:active{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (prefers-color-scheme:dark){.dark\:bg-zinc-800{--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity))}.dark\:bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity))}.dark\:text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity))}.dark\:text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity))}.dark\:text-zinc-50{--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity))}.dark\:shadow-none{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.dark\:hover\:text-indigo-400\/90:hover{color:rgba(129,140,248,.9)}.dark\:active\:text-indigo-500:active{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}}@media (min-width:640px){.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:pl-0{padding-left:0}.sm\:pt-0{padding-top:0}.sm\:text-6xl{font-size:4rem}}@media (min-width:768px){.md\:text-3xl{font-size:1.875rem}}@media (min-width:1024px){.lg\:order-1{order:1}.lg\:order-2{order:2}.lg\:w-6\/12{width:50%}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:py-16{padding-bottom:4rem;padding-top:4rem}} -------------------------------------------------------------------------------- /docs/yellow-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-stone/browserosaurus/9e727b5fc4677b17c245660158e5480ae3df8a76/docs/yellow-button.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import config from '@will-stone/eslint-config' 2 | 3 | export default config() 4 | -------------------------------------------------------------------------------- /forge.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { MakerZIP } from '@electron-forge/maker-zip' 4 | import { VitePlugin } from '@electron-forge/plugin-vite' 5 | 6 | /** @type {import('@electron-forge/shared-types').ForgeConfig} */ 7 | const config = { 8 | makers: [new MakerZIP({}, ['darwin'])], 9 | packagerConfig: { 10 | appBundleId: 'com.browserosaurus', 11 | appCategoryType: 'public.app-category.developer-tools', 12 | asar: false, 13 | extendInfo: 'plist/Info.plist', 14 | icon: 'src/shared/static/icon/icon.icns', 15 | osxNotarize: process.env.CI 16 | ? undefined 17 | : { 18 | keychain: '~/Library/Keychains/login.keychain-db', 19 | keychainProfile: 'AC_PASSWORD', 20 | }, 21 | osxSign: process.env.CI 22 | ? undefined 23 | : { 24 | optionsForFile: () => ({ 25 | entitlements: 'plist/entitlements.mac.plist', 26 | 'hardened-runtime': true, 27 | }), 28 | }, 29 | protocols: [ 30 | { 31 | name: 'HTTP link', 32 | schemes: ['http', 'https'], 33 | }, 34 | { 35 | name: 'File', 36 | schemes: ['file'], 37 | }, 38 | ], 39 | }, 40 | plugins: [ 41 | new VitePlugin({ 42 | build: [ 43 | { 44 | config: 'vite.main.config.ts', 45 | entry: 'src/main/main.ts', 46 | }, 47 | { 48 | config: 'vite.preload.config.ts', 49 | entry: 'src/renderers/shared/preload.ts', 50 | }, 51 | ], 52 | renderer: [ 53 | { 54 | config: 'vite.renderer.prefs.config.ts', 55 | name: 'prefs_window', 56 | }, 57 | { 58 | config: 'vite.renderer.picker.config.ts', 59 | name: 'picker_window', 60 | }, 61 | ], 62 | }), 63 | ], 64 | } 65 | 66 | export default config 67 | -------------------------------------------------------------------------------- /guide/creating-app-icon.md: -------------------------------------------------------------------------------- 1 | # Creating app icon 2 | 3 | This document is for the maintainer. If you are contributing to the 4 | Browserosaurus project, you will not need to follow these steps. 5 | 6 | To build an icns file (app icon) from `src/shared/static/icon/icon.png`, simply 7 | run `npm run icns`. 8 | -------------------------------------------------------------------------------- /guide/privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | ## Does Browserosaurus collect any of my data and send it to an online service? 4 | 5 | No. 6 | 7 | ## Not even the link I click? 8 | 9 | No. The only outgoing call Browserosaurus makes is to check for an update. 10 | -------------------------------------------------------------------------------- /guide/publishing.md: -------------------------------------------------------------------------------- 1 | # Publishing 2 | 3 | This document is for the maintainer. If you are contributing to the 4 | Browserosaurus project, you will not need to follow these steps. 5 | 6 | Setup Keychain for notarization: 7 | 8 | ```sh 9 | xcrun notarytool store-credentials "AC_PASSWORD" --apple-id "email@example.com" --team-id "team-id" --password "app-password" --keychain "~/Library/Keychains/login.keychain-db" 10 | ``` 11 | 12 | This will create an item called `com.apple.gke.notary.tool` in your `login` 13 | keychain. 14 | 15 | - "AC_PASSWORD" is the name to be given to the keychain profile, and can be left 16 | as-is. 17 | - The apple ID is usually your email address associated with your Apple 18 | Developer account. 19 | - The Team ID can be found here: 20 | https://developer.apple.com/account/#!/membership/ 21 | - Password is the app-specific password that can be configured here: 22 | https://appleid.apple.com/account/manage 23 | - I have found it best to store the generated item in the `login` keychain, and 24 | the location used above is usually where it is found. 25 | 26 | Make sure the _Developer ID Application_ and _Developer ID Installer_ 27 | certificates are in you keychain and have private keys attached to them. If this 28 | is a new mac, then can be exported from the old mac's keychain. Make sure to 29 | give the export a passphrase otherwise the private keys will not be exported. 30 | Failing that, restart the old mac and try again. 31 | 32 | The following command will prompt to bump version number, package, notarize, and 33 | make ZIP bundle: 34 | 35 | ``` 36 | npm run release 37 | ``` 38 | 39 | The zip files can then be added to a GitHub release. 40 | -------------------------------------------------------------------------------- /guide/setting-up-for-development.md: -------------------------------------------------------------------------------- 1 | # Setting up for development 2 | 3 | Ensure you are running the correct version of Node. The repo includes an 4 | `.nvmrc` file that includes the version number I use. 5 | 6 | Clone your fork of this repository: 7 | 8 | ``` 9 | git clone git@github.com:USERNAME/browserosaurus.git 10 | ``` 11 | 12 | Move to folder: 13 | 14 | ``` 15 | cd browserosaurus 16 | ``` 17 | 18 | Install dependencies: 19 | 20 | ``` 21 | npm i 22 | ``` 23 | 24 | Run Browserosaurus in dev mode: 25 | 26 | ``` 27 | npm start 28 | ``` 29 | 30 | > ℹ️ If you already have a copy of Browserosaurus installed, it's advisable to 31 | > quit that first as the two apps will look identical in the menubar. 32 | 33 | > ℹ️ When setting the development Browserosaurus as the default browser, you 34 | > will need to select "Electron". 35 | -------------------------------------------------------------------------------- /guide/supporting-a-browser-or-app.md: -------------------------------------------------------------------------------- 1 | # Supporting a browser or app 2 | 3 | Adding and maintaining the available browsers and apps is a community effort; I 4 | have now added support for all the browsers that I use, and any newly requested 5 | apps are either too niche or behind pay walls. However, by following this 6 | document you can add the browser yourself and submit it to be included in 7 | Browserosaurus. Don't worry, even if you've never contributed to an open source 8 | project before, I'll take you through the steps of how to add support for a new 9 | browser, and if something is confusing or you'd like a little extra help, please 10 | [ask on the discussions forum](https://github.com/will-stone/browserosaurus/discussions/categories/q-a), 11 | or even send a 12 | [pull request](https://github.com/will-stone/browserosaurus/pulls) to improve 13 | this documentation. 14 | 15 | > 🚨 Any apps that receive an issue and are not fixed via a pull request, will 16 | > be removed from subsequent releases. 17 | 18 | ## Prerequisite 19 | 20 | Fork the project to your GitHub account, and then make sure you are 21 | [set-up for development](./setting-up-for-development.md). 22 | 23 | ## Adding a new browser 24 | 25 | Using your text editor (I recommend 26 | [Visual Studio Code](https://code.visualstudio.com/)), open the 27 | `/src/config/apps.ts` file. After all the import statements, you'll see an 28 | `apps` object that contains all of the apps that Browserosaurus can find on a 29 | user's system. The key to each app object is the app name (as written in the 30 | `/Applications` folder). Add your new app to the list: 31 | 32 | ```ts 33 | export const apps = typeApps({ 34 | // ... 35 | Firefox: {}, 36 | // ... 37 | }) 38 | ``` 39 | 40 | > ℹ️ The app objects within the root `apps` object should be in alphabetical. 41 | > There's an ESLint rule that will check for this. 42 | 43 | That's all there is to it. Run your updated code using `npm start`, and see if 44 | it behaves how you would expect. 45 | 46 | ## Extras 47 | 48 | ### Private / Incognito Mode 49 | 50 | Some browsers support opening in a _private_ or _incognito_ mode. Browserosaurus 51 | can be set to open the given URL in private mode when holding the 52 | shift key and clicking an app icon or using its hotkey. If you'd like 53 | to support this with your added browser, you will need to find the 54 | [command-line argument](https://en.wikipedia.org/wiki/Command-line_interface#Arguments) 55 | that your browser uses when opening URLs from the command-line. In the case of 56 | Firefox this is `--private-window`: 57 | 58 | ```ts 59 | export const apps = typeApps({ 60 | // ... 61 | Firefox: { 62 | privateArg: '--private-window', 63 | }, 64 | // ... 65 | }) 66 | ``` 67 | 68 | ### URL Template 69 | 70 | If you're adding an app that uses a different protocol, where the URL is just a 71 | parameter, you can use `convertUrl`. For example, the Pocket app is set like so: 72 | 73 | ```ts 74 | export const apps = typeApps({ 75 | // ... 76 | Pocket: { 77 | convertUrl: (url) => `pocket://add?url=${url}`, 78 | }, 79 | // ... 80 | }) 81 | ``` 82 | 83 | ## Testing 84 | 85 | There are a few tests that will check the compatibility of your `apps.ts` file. 86 | Run `npm test` and make sure all tests successfully pass. If any tests fail, and 87 | you are unsure about the results, please submit your changes anyway and we can 88 | discuss it on the pull request page. 89 | 90 | ## Submit your changes 91 | 92 | Commit and push your changes to your GitHub fork of Browserosaurus, then open a 93 | [pull request](https://github.com/will-stone/browserosaurus/pulls) to merge your 94 | branch into Browserosaurus' `main` branch. Please note that this repository now 95 | follows the 96 | [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard, 97 | and so please structure your commit messages accordingly. The example above's 98 | commit message could look something like this: 99 | `feat: add support for Firefox browser`. 100 | -------------------------------------------------------------------------------- /jest-setup-files-after-environment.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | 3 | // Not available in jsdom so must be mocked 4 | globalThis.HTMLElement.prototype.scrollTo = jest.fn 5 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types' 2 | 3 | // Sync object 4 | const config: Config.InitialOptions = { 5 | moduleNameMapper: { 6 | '\\.(css|less)$': '/__mocks__/style-mock.js', 7 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 8 | '/__mocks__/file-mock.js', 9 | '^(\\.{1,2}/.*)\\.js$': '$1', 10 | }, 11 | modulePathIgnorePatterns: ['/out/'], 12 | preset: 'ts-jest', 13 | setupFilesAfterEnv: ['/jest-setup-files-after-environment.ts'], 14 | testEnvironment: 'jsdom', 15 | testPathIgnorePatterns: ['/node_modules/', '/out/'], 16 | } 17 | 18 | export default config 19 | -------------------------------------------------------------------------------- /lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | '*.{css,json,md}': ['prettier --write'], 3 | '*.{js,jsx,ts,tsx,cjs,mjs}': [ 4 | 'cross-env ESLINT_USE_FLAT_CONFIG=true eslint -c eslint.config.mjs --fix', 5 | ], 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browserosaurus", 3 | "version": "20.11.0", 4 | "description": "The browser prompter for macOS", 5 | "keywords": [ 6 | "macOS", 7 | "Electron", 8 | "Browser chooser" 9 | ], 10 | "homepage": "https://wstone.io/browserosaurus", 11 | "bugs": { 12 | "url": "https://github.com/will-stone/browserosaurus/issues" 13 | }, 14 | "repository": "https://github.com/will-stone/browserosaurus", 15 | "license": "GPL-3.0-only", 16 | "author": "Will Stone", 17 | "type": "module", 18 | "main": ".vite/build/main.js", 19 | "scripts": { 20 | "doctor": "npm run lint && npm run typecheck && npm run test", 21 | "icns": "cd ./src/shared/static/icon && ../../../../scripts/png2icns.sh icon.png", 22 | "lint": "eslint .", 23 | "list-apps": "ts-node ./scripts/list-installed-apps", 24 | "make": "electron-forge make --arch=x64,arm64", 25 | "make:arm": "electron-forge make --arch=arm64", 26 | "make:intel": "electron-forge make --arch=x64", 27 | "prepackage": "rimraf out", 28 | "package": "NODE_ENV=production electron-forge package --platform=darwin --arch=x64,arm64", 29 | "package:arm": "NODE_ENV=production electron-forge package --platform=darwin --arch=arm64", 30 | "package:intel": "NODE_ENV=production electron-forge package --platform=darwin --arch=x64", 31 | "prepare": "husky", 32 | "release": "npm run doctor && bump --commit \"chore: release v\" && npm run make", 33 | "start": "ELECTRON_DISABLE_SECURITY_WARNINGS=true electron-forge start", 34 | "test": "jest", 35 | "typecheck": "tsc --noEmit --skipLibCheck", 36 | "website-css:build": "npx tailwindcss -i node_modules/tailwindcss/tailwind.css -o ./docs/style.css --content docs/index.html -m", 37 | "website-css:watch": "npx tailwindcss -i node_modules/tailwindcss/tailwind.css -o ./docs/style.css --content docs/index.html -m --watch" 38 | }, 39 | "dependencies": { 40 | "file-icon": "^5.1.1" 41 | }, 42 | "devDependencies": { 43 | "@commitlint/cli": "^19.5.0", 44 | "@commitlint/config-conventional": "^19.5.0", 45 | "@dnd-kit/core": "^6.1.0", 46 | "@dnd-kit/sortable": "^8.0.0", 47 | "@dnd-kit/utilities": "^3.2.2", 48 | "@electron-forge/cli": "~7.2.0", 49 | "@electron-forge/maker-zip": "~7.2.0", 50 | "@electron-forge/plugin-base": "~7.2.0", 51 | "@electron-forge/plugin-vite": "~7.2.0", 52 | "@electron-forge/plugin-webpack": "~7.2.0", 53 | "@electron-forge/shared-types": "~7.2.0", 54 | "@reduxjs/toolkit": "^2.3.0", 55 | "@testing-library/jest-dom": "^6.6.2", 56 | "@testing-library/react": "^16.0.1", 57 | "@timfish/forge-externals-plugin": "^0.2.1", 58 | "@types/jest": "^29.5.13", 59 | "@types/lodash": "^4.17.10", 60 | "@types/node": "^20.16.0", 61 | "@types/react": "^18.3.11", 62 | "@types/react-dom": "^18.3.1", 63 | "@types/react-redux": "^7.1.34", 64 | "@vercel/webpack-asset-relocator-loader": "^1.7.4", 65 | "@will-stone/eslint-config": "^12.0.0", 66 | "@will-stone/prettier-config": "^8.0.1", 67 | "app-exists": "^2.1.1", 68 | "axios": "^1.7.7", 69 | "clsx": "^2.1.1", 70 | "cross-env": "^7.0.3", 71 | "cssnano": "^7.0.6", 72 | "electron": "^33.0.1", 73 | "electron-log": "^5.2.0", 74 | "eslint": "^9.12.0", 75 | "fast-deep-equal": "^3.1.3", 76 | "husky": "^9.1.6", 77 | "immer": "^10.1.1", 78 | "jest": "^29.7.0", 79 | "jest-environment-jsdom": "^29.7.0", 80 | "lint-staged": "^15.2.10", 81 | "lodash": "^4.17.21", 82 | "lowdb": "^6.0.1", 83 | "picocolors": "^1.1.1", 84 | "postcss": "^8.4.47", 85 | "postcss-cli": "^11.0.0", 86 | "postcss-import": "^16.1.0", 87 | "prettier": "^3.3.3", 88 | "react": "^18.3.1", 89 | "react-dom": "^18.3.1", 90 | "react-redux": "^9.1.2", 91 | "redux": "^5.0.1", 92 | "rimraf": "^6.0.1", 93 | "tailwindcss": "^3.4.14", 94 | "tings": "^9.1.0", 95 | "ts-jest": "^29.2.5", 96 | "ts-node": "^10.9.2", 97 | "typescript": "^5.6.3", 98 | "version-bump-prompt": "^6.1.0", 99 | "vite": "^5.4.9" 100 | }, 101 | "engines": { 102 | "node": ">=16.0.0" 103 | }, 104 | "overrides": { 105 | "eslint": "^9.12.0", 106 | "vite": "^5.4.9" 107 | }, 108 | "productName": "Browserosaurus" 109 | } 110 | -------------------------------------------------------------------------------- /plist/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSUIElement 6 | 7 | CFBundleDisplayName 8 | Browserosaurus 9 | CFBundleDocumentTypes 10 | 11 | 12 | CFBundleTypeExtensions 13 | 14 | html 15 | 16 | CFBundleTypeIconFile 17 | icon.icns 18 | CFBundleTypeName 19 | HyperText Markup File 20 | CFBundleTypeRole 21 | Viewer 22 | LSHandlerRank 23 | Default 24 | 25 | 26 | CFBundleTypeExtensions 27 | 28 | xhtml 29 | 30 | CFBundleTypeIconFile 31 | icon.icns 32 | CFBundleTypeName 33 | Extensible HyperText Markup File 34 | CFBundleTypeRole 35 | Viewer 36 | LSHandlerRank 37 | Default 38 | 39 | 40 | CFBundleTypeExtensions 41 | 42 | htm 43 | 44 | CFBundleTypeIconFile 45 | icon.icns 46 | CFBundleTypeName 47 | HyperText Markup File 48 | CFBundleTypeRole 49 | Viewer 50 | LSHandlerRank 51 | Default 52 | 53 | 54 | CFBundleTypeExtensions 55 | 56 | shtml 57 | 58 | CFBundleTypeIconFile 59 | icon.icns 60 | CFBundleTypeName 61 | HyperText Markup File 62 | CFBundleTypeRole 63 | Viewer 64 | LSHandlerRank 65 | Default 66 | 67 | 68 | CFBundleTypeExtensions 69 | 70 | xht 71 | 72 | CFBundleTypeIconFile 73 | icon.icns 74 | CFBundleTypeName 75 | Extensible HyperText Markup File 76 | CFBundleTypeRole 77 | Viewer 78 | LSHandlerRank 79 | Default 80 | 81 | 82 | CFBundleURLTypes 83 | 84 | 85 | CFBundleTypeRole 86 | Viewer 87 | CFBundleURLName 88 | HyperText Transfer Protocol 89 | CFBundleURLSchemes 90 | 91 | ftp 92 | http 93 | https 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /plist/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | 5 | cssnano: {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('@will-stone/prettier-config') 2 | -------------------------------------------------------------------------------- /scripts/list-installed-apps.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process' 2 | import path from 'node:path' 3 | 4 | import { apps } from '../src/config/apps' 5 | 6 | const allInstalledAppNames = new Set( 7 | execSync( 8 | 'find ~/Applications /Applications -iname "*.app" -prune -not -path "*/.*" 2>/dev/null ||true', 9 | ) 10 | .toString() 11 | .trim() 12 | .split('\n') 13 | .map((appPath) => path.parse(appPath).name), 14 | ) 15 | 16 | const installedApps = Object.keys(apps).filter((appName) => 17 | allInstalledAppNames.has(appName), 18 | ) 19 | 20 | // eslint-disable-next-line no-console 21 | console.log(installedApps) 22 | -------------------------------------------------------------------------------- /scripts/png2icns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # chmod +x png2icns.sh 4 | # ./png2icns.sh icon.png 5 | 6 | # Exec Paths 7 | SIPS='/usr/bin/sips' 8 | ICONUTIL='/usr/bin/iconutil' 9 | if [ ! -x "${SIPS}" ]; then 10 | echo "Cannot find required SIPS executable at: ${SIPS}" >&2 11 | exit 1; 12 | fi 13 | if [ ! -x "${ICONUTIL}" ]; then 14 | echo "Cannot find required ICONUTIL executable at: ${ICONUTIL}" >&2 15 | exit 1; 16 | fi 17 | 18 | # Parameters 19 | SOURCE=$1 20 | 21 | # Get source image 22 | if [ -z "${SOURCE}" ]; then 23 | echo "No source image specified, searching in current directory...\c" 24 | SOURCE=$( ls *.png | head -n1 ) 25 | if [ -z "${SOURCE}" ]; then 26 | echo "No source image specified and none found." 27 | exit 1; 28 | else 29 | echo "FOUND"; 30 | fi 31 | fi 32 | 33 | 34 | # File Infrastructure 35 | NAME=$(basename "${SOURCE}") 36 | EXT="${NAME##*.}" 37 | BASE="${NAME%.*}" 38 | ICONSET="${BASE}.iconset" 39 | 40 | # Debug Info 41 | echo "SOURCE: ${SOURCE}" 42 | echo "NAME: $NAME" 43 | echo "BASE: $BASE" 44 | echo "EXT: $EXT" 45 | echo "ICONSET: $ICONSET" 46 | 47 | # Get source image info 48 | SRCWIDTH=$( $SIPS -g pixelWidth "${SOURCE}" | tail -n1 | awk '{print $2}') 49 | SRCHEIGHT=$( $SIPS -g pixelHeight "${SOURCE}" | tail -n1 | awk '{print $2}' ) 50 | SRCFORMAT=$( $SIPS -g format "${SOURCE}" | tail -n1 | awk '{print $2}' ) 51 | 52 | # Debug Info 53 | echo "SRCWIDTH: $SRCWIDTH" 54 | echo "SRCHEIGHT: $SRCHEIGHT" 55 | echo "SRCFORMAT: $SRCFORMAT" 56 | 57 | # Check The Source Image 58 | if [ "x${SRCWIDTH}" != "x1024" ] || [ "x${SRCHEIGHT}" != "x1024" ]; then 59 | echo "ERR: Source image should be 1024 x 1024 pixels." >&2 60 | exit 1; 61 | fi 62 | if [ "x${SRCFORMAT}" != "xpng" ]; then 63 | echo "ERR: Source image format should be png." >&2 64 | exit 1; 65 | fi 66 | 67 | # Resample image into iconset 68 | mkdir "${ICONSET}" 69 | $SIPS -s format png --resampleWidth 1024 "${SOURCE}" --out "${ICONSET}/icon_512x512@2x.png" > /dev/null 2>&1 70 | $SIPS -s format png --resampleWidth 512 "${SOURCE}" --out "${ICONSET}/icon_512x512.png" > /dev/null 2>&1 71 | cp "${ICONSET}/icon_512x512.png" "${ICONSET}/icon_256x256@2x.png" 72 | $SIPS -s format png --resampleWidth 256 "${SOURCE}" --out "${ICONSET}/icon_256x256.png" > /dev/null 2>&1 73 | cp "${ICONSET}/icon_256x256.png" "${ICONSET}/icon_128x128@2x.png" 74 | $SIPS -s format png --resampleWidth 128 "${SOURCE}" --out "${ICONSET}/icon_128x128.png" > /dev/null 2>&1 75 | $SIPS -s format png --resampleWidth 64 "${SOURCE}" --out "${ICONSET}/icon_32x32@2x.png" > /dev/null 2>&1 76 | $SIPS -s format png --resampleWidth 32 "${SOURCE}" --out "${ICONSET}/icon_32x32.png" > /dev/null 2>&1 77 | cp "${ICONSET}/icon_32x32.png" "${ICONSET}/icon_16x16@2x.png" 78 | $SIPS -s format png --resampleWidth 16 "${SOURCE}" --out "${ICONSET}/icon_16x16.png" > /dev/null 2>&1 79 | 80 | # Create an icns file from the iconset 81 | $ICONUTIL -c icns "${ICONSET}" 82 | 83 | # Clean up the iconset 84 | rm -rf "${ICONSET}" 85 | -------------------------------------------------------------------------------- /src/config/apps.test.ts: -------------------------------------------------------------------------------- 1 | import { getKeys } from '../shared/utils/get-keys.js' 2 | import { apps } from './apps.js' 3 | 4 | test.each(getKeys(apps))( 5 | '%s should not include anything but allowed keys', 6 | (input) => { 7 | const allowedKeys = new Set(['name', 'convertUrl', 'privateArg']) 8 | 9 | const unknownKeys = getKeys(apps[input]).filter( 10 | (key) => !allowedKeys.has(key), 11 | ) 12 | 13 | expect(unknownKeys).toHaveLength(0) 14 | }, 15 | ) 16 | 17 | test('should have apps in alphabetical order by name', () => { 18 | const appNames = Object.keys(apps).map((appName) => appName.toLowerCase()) 19 | const sortedAppNames = [...appNames].sort() 20 | 21 | expect(appNames).toStrictEqual(sortedAppNames) 22 | }) 23 | -------------------------------------------------------------------------------- /src/config/apps.ts: -------------------------------------------------------------------------------- 1 | type App = { 2 | privateArg?: string 3 | convertUrl?: (url: string) => string 4 | } 5 | 6 | const typeApps = >(apps: T) => apps 7 | 8 | const apps = typeApps({ 9 | Arc: {}, 10 | Basilisk: {}, 11 | Blisk: {}, 12 | 'Brave Browser': { 13 | privateArg: '--incognito', 14 | }, 15 | 'Brave Browser Beta': { 16 | privateArg: '--incognito', 17 | }, 18 | 'Brave Browser Nightly': { 19 | privateArg: '--incognito', 20 | }, 21 | 'Brave Dev': { 22 | privateArg: '--incognito', 23 | }, 24 | Chromium: { 25 | privateArg: '--incognito', 26 | }, 27 | 'Chromium-Gost': { 28 | privateArg: '--incognito', 29 | }, 30 | Discord: { 31 | convertUrl: (url) => 32 | url.replace( 33 | /^https?:\/\/(?:(?:ptb|canary)\.)?discord\.com\//u, 34 | 'discord://-/', 35 | ), 36 | }, 37 | 'Discord Canary': { 38 | convertUrl: (url) => 39 | url.replace( 40 | /^https?:\/\/(?:(?:ptb|canary)\.)?discord\.com\//u, 41 | 'discord://-/', 42 | ), 43 | }, 44 | 'Discord PTB': { 45 | convertUrl: (url) => 46 | url.replace( 47 | /^https?:\/\/(?:(?:ptb|canary)\.)?discord\.com\//u, 48 | 'discord://-/', 49 | ), 50 | }, 51 | Dissenter: {}, 52 | DuckDuckGo: {}, 53 | Epic: {}, 54 | Figma: {}, 55 | 'Figma Beta': {}, 56 | Finicky: {}, 57 | Firefox: { 58 | privateArg: '--private-window', 59 | }, 60 | 'Firefox Developer Edition': { 61 | privateArg: '--private-window', 62 | }, 63 | 'Firefox Nightly': { 64 | privateArg: '--private-window', 65 | }, 66 | Floorp: {}, 67 | Framer: {}, 68 | FreeTube: {}, 69 | 'Google Chrome': { 70 | privateArg: '--incognito', 71 | }, 72 | 'Google Chrome Beta': { 73 | privateArg: '--incognito', 74 | }, 75 | 'Google Chrome Canary': { 76 | privateArg: '--incognito', 77 | }, 78 | 'Google Chrome Dev': { 79 | privateArg: '--incognito', 80 | }, 81 | IceCat: { 82 | privateArg: '--private-window', 83 | }, 84 | Iridium: {}, 85 | Island: {}, 86 | Lagrange: {}, 87 | LibreWolf: { 88 | privateArg: '--private-window', 89 | }, 90 | Linear: {}, 91 | Maxthon: {}, 92 | 'Microsoft Edge': { 93 | privateArg: '--inprivate', 94 | }, 95 | 'Microsoft Edge Beta': { 96 | privateArg: '--inprivate', 97 | }, 98 | 'Microsoft Edge Canary': { 99 | privateArg: '--inprivate', 100 | }, 101 | 'Microsoft Edge Dev': { 102 | privateArg: '--inprivate', 103 | }, 104 | 'Microsoft Teams': { 105 | convertUrl: (url) => 106 | url.replace('https://teams.microsoft.com/', 'msteams:/'), 107 | }, 108 | 'Microsoft Teams (work or school)': { 109 | convertUrl: (url) => 110 | url.replace('https://teams.microsoft.com/', 'msteams:/'), 111 | }, 112 | 'Microsoft Teams classic': { 113 | convertUrl: (url) => 114 | url.replace('https://teams.microsoft.com/', 'msteams:/'), 115 | }, 116 | Min: {}, 117 | Miro: {}, 118 | 'Mullvad Browser': { 119 | privateArg: '--private-window', 120 | }, 121 | 'NAVER Whale': {}, 122 | Notion: {}, 123 | Opera: {}, 124 | 'Opera Beta': {}, 125 | 'Opera CD': {}, 126 | 'Opera Crypto': {}, 127 | 'Opera Dev': {}, 128 | 'Opera Developer': {}, 129 | 'Opera GX': {}, 130 | 'Opera Neon': {}, 131 | Orion: {}, 132 | 'Orion RC': {}, 133 | 'Pale Moon': {}, 134 | Pocket: { 135 | convertUrl: (url) => `pocket://add?url=${url}`, 136 | }, 137 | Polypane: {}, 138 | 'PrivateWindow': {}, 139 | 'PublicWindow': {}, 140 | qutebrowser: {}, 141 | Safari: {}, 142 | 'Safari Technology Preview': {}, 143 | Sidekick: { 144 | privateArg: '--incognito', 145 | }, 146 | SigmaOS: {}, 147 | Sizzy: {}, 148 | Slack: {}, 149 | Spotify: {}, 150 | Thorium: { 151 | privateArg: '--incognito', 152 | }, 153 | 'Tor Browser': {}, 154 | Twilight: { 155 | privateArg: '--incognito', 156 | }, 157 | Twitter: {}, 158 | Ulaa: { 159 | privateArg: '--incognito', 160 | }, 161 | Vivaldi: {}, 162 | 'Vivaldi Snapshot': {}, 163 | Waterfox: {}, 164 | Wavebox: { 165 | privateArg: '--incognito', 166 | }, 167 | Whist: {}, 168 | Yandex: {}, 169 | Yattee: {}, 170 | 'Zen': { 171 | privateArg: '--incognito', 172 | }, 173 | 'Zen Browser': {}, 174 | 'zoom.us': {}, 175 | }) 176 | 177 | type Apps = typeof apps 178 | 179 | type AppName = keyof typeof apps 180 | 181 | export { AppName, Apps, apps } 182 | -------------------------------------------------------------------------------- /src/config/constants.ts: -------------------------------------------------------------------------------- 1 | const CARROT_URL = 'https://www.buymeacoffee.com/wstone' 2 | const B_URL = 'https://github.com/will-stone/browserosaurus' 3 | const ISSUES_URL = 'https://github.com/will-stone/browserosaurus/issues' 4 | 5 | export { B_URL, CARROT_URL, ISSUES_URL } 6 | -------------------------------------------------------------------------------- /src/main/database.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | 3 | import { app } from 'electron' 4 | import { LowSync } from 'lowdb' 5 | import { JSONFileSync } from 'lowdb/node' 6 | 7 | import type { Storage } from '../shared/state/reducer.storage.js' 8 | import { defaultStorage } from '../shared/state/reducer.storage.js' 9 | 10 | const keys = Object.keys as (o: T) => Extract[] 11 | 12 | const STORAGE_FILE = path.join(app.getPath('userData'), 'store.json') 13 | 14 | const adapter = new JSONFileSync(STORAGE_FILE) 15 | const lowdb = new LowSync(adapter, defaultStorage) 16 | 17 | export const database = { 18 | get: (key: Key): Storage[Key] => { 19 | return database.getAll()[key] 20 | }, 21 | 22 | set: (key: Key, value: Storage[Key]): void => { 23 | lowdb.read() 24 | 25 | if (lowdb.data === null) { 26 | lowdb.data = defaultStorage 27 | } 28 | 29 | lowdb.data[key] = value 30 | lowdb.write() 31 | }, 32 | 33 | getAll: (): Storage => { 34 | lowdb.read() 35 | 36 | if (lowdb.data === null) { 37 | return defaultStorage 38 | } 39 | 40 | // Removes unknown keys in storage 41 | for (const key of keys(lowdb.data)) { 42 | if (defaultStorage[key] === undefined) { 43 | delete lowdb.data[key] 44 | } 45 | } 46 | 47 | // Remove old, id-based apps 48 | if (Array.isArray(lowdb.data.apps)) { 49 | lowdb.data = { 50 | ...lowdb.data, 51 | apps: lowdb.data.apps.filter((storedApp) => Boolean(storedApp.name)), 52 | } 53 | } 54 | 55 | return { 56 | ...defaultStorage, 57 | ...lowdb.data, 58 | } 59 | }, 60 | 61 | setAll: (value: Storage): void => { 62 | lowdb.data = value 63 | lowdb.write() 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import type { UnknownAction } from '@reduxjs/toolkit' 2 | import electron, { app } from 'electron' 3 | import { sleep } from 'tings' 4 | 5 | import { Channel } from '../shared/state/channels.js' 6 | import { openedUrl, readiedApp } from './state/actions.js' 7 | import { dispatch, getState } from './state/store.js' 8 | 9 | app.on('ready', () => dispatch(readiedApp())) 10 | 11 | // App doesn't always close on ctrl-c in console, this fixes that 12 | app.on('before-quit', () => app.exit()) 13 | 14 | app.on('open-url', (event, url) => { 15 | event.preventDefault() 16 | 17 | const urlOpener = async () => { 18 | if (getState().data.pickerStarted) { 19 | dispatch(openedUrl(url)) 20 | } 21 | // If B was opened by sending it a URL, the `open-url` electron.app event 22 | // can be fired before the picker window is ready. Here we wait before trying again. 23 | else { 24 | await sleep(500) 25 | urlOpener() 26 | } 27 | } 28 | 29 | urlOpener() 30 | }) 31 | 32 | /** 33 | * Enter actions from renderer into main's store's queue 34 | */ 35 | electron.ipcMain.on(Channel.PREFS, (_, action: UnknownAction) => 36 | dispatch(action), 37 | ) 38 | electron.ipcMain.on(Channel.PICKER, (_, action: UnknownAction) => 39 | dispatch(action), 40 | ) 41 | -------------------------------------------------------------------------------- /src/main/state/actions.ts: -------------------------------------------------------------------------------- 1 | import type { Rectangle } from 'electron/main' 2 | 3 | import type { AppName } from '../../config/apps.js' 4 | import type { Data } from '../../shared/state/reducer.data.js' 5 | import type { Storage } from '../../shared/state/reducer.storage.js' 6 | import { actionNamespacer } from '../../shared/utils/action-namespacer.js' 7 | 8 | const main = actionNamespacer('main') 9 | 10 | const readiedApp = main('app/readied') 11 | 12 | const openedUrl = main('url/opened') 13 | 14 | const changedPickerWindowBounds = main( 15 | 'picker-window-bounds/changed', 16 | ) 17 | 18 | const startedScanning = main('installed-apps/scanning') 19 | 20 | const retrievedInstalledApps = main('installed-apps/retrieved') 21 | 22 | const receivedRendererStartupSignal = main<{ data: Data; storage: Storage }>( 23 | 'sync-reducers', 24 | ) 25 | 26 | const gotDefaultBrowserStatus = main('default-browser-status/got') 27 | 28 | const gotAppIcons = main>>('app-icons/got') 29 | 30 | const availableUpdate = main('update/available') 31 | const downloadingUpdate = main('update/downloading') 32 | const downloadedUpdate = main('update/downloaded') 33 | 34 | const tray = actionNamespacer('tray') 35 | 36 | const clickedRestorePicker = tray('restore-picker/clicked') 37 | const clickedOpenPrefs = tray('open-prefs/clicked') 38 | 39 | export { 40 | availableUpdate, 41 | changedPickerWindowBounds, 42 | clickedOpenPrefs, 43 | clickedRestorePicker, 44 | downloadedUpdate, 45 | downloadingUpdate, 46 | gotAppIcons, 47 | gotDefaultBrowserStatus, 48 | openedUrl, 49 | readiedApp, 50 | receivedRendererStartupSignal, 51 | retrievedInstalledApps, 52 | startedScanning, 53 | } 54 | -------------------------------------------------------------------------------- /src/main/state/middleware.action-hub.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/callback-return -- must flush middleware to get nextState */ 2 | /* eslint-disable unicorn/prefer-regexp-test -- rtk uses .match */ 3 | import { app, autoUpdater, shell } from 'electron' 4 | import deepEqual from 'fast-deep-equal' 5 | 6 | import { B_URL, ISSUES_URL } from '../../config/constants.js' 7 | import { 8 | clickedApp, 9 | clickedUpdateBar, 10 | clickedUrlBar, 11 | pressedKey, 12 | startedPicker, 13 | } from '../../renderers/picker/state/actions.js' 14 | import { 15 | clickedHomepageButton, 16 | clickedOpenIssueButton, 17 | clickedRescanApps, 18 | clickedSetAsDefaultBrowserButton, 19 | clickedUpdateButton, 20 | clickedUpdateRestartButton, 21 | confirmedReset, 22 | startedPrefs, 23 | } from '../../renderers/prefs/state/actions.js' 24 | import type { Middleware } from '../../shared/state/model.js' 25 | import type { RootState } from '../../shared/state/reducer.root.js' 26 | import { database } from '../database.js' 27 | import { createTray } from '../tray.js' 28 | import copyUrlToClipboard from '../utils/copy-url-to-clipboard.js' 29 | import { getAppIcons } from '../utils/get-app-icons.js' 30 | import { getInstalledAppNames } from '../utils/get-installed-app-names.js' 31 | import { initUpdateChecker } from '../utils/init-update-checker.js' 32 | import { openApp } from '../utils/open-app.js' 33 | // import { removeWindowsFromMemory } from '../utils/remove-windows-from-memory' 34 | import { 35 | createWindows, 36 | pickerWindow, 37 | prefsWindow, 38 | showPickerWindow, 39 | showPrefsWindow, 40 | } from '../windows.js' 41 | import { 42 | clickedOpenPrefs, 43 | clickedRestorePicker, 44 | openedUrl, 45 | readiedApp, 46 | receivedRendererStartupSignal, 47 | retrievedInstalledApps, 48 | } from './actions.js' 49 | 50 | /** 51 | * Asynchronously update perma store on state.storage changes 52 | */ 53 | function updateDatabase( 54 | previousState: RootState, 55 | nextState: RootState, 56 | ): Promise { 57 | return new Promise((resolve) => { 58 | if (!deepEqual(previousState.storage, nextState.storage)) { 59 | database.setAll(nextState.storage) 60 | } 61 | 62 | resolve() 63 | }) 64 | } 65 | 66 | /** 67 | * Actions that need to be dealt with by main. 68 | */ 69 | export const actionHubMiddleware = 70 | (): Middleware => 71 | ({ dispatch, getState }) => 72 | (next) => 73 | (action) => { 74 | const previousState = getState() 75 | 76 | // Initial request to prompt to become default browser 77 | // Check must happen before reducer run, before isSetup set to false 78 | if (readiedApp.match(action) && !previousState.storage.isSetup) { 79 | app.setAsDefaultProtocolClient('http') 80 | app.setAsDefaultProtocolClient('https') 81 | } 82 | 83 | // Move to next middleware 84 | const result = next(action) 85 | 86 | const nextState = getState() 87 | 88 | updateDatabase(previousState, nextState) 89 | 90 | // Main's process is ready 91 | if (readiedApp.match(action)) { 92 | // Hide from dock and cmd-tab 93 | app.dock.hide() 94 | createWindows() 95 | createTray() 96 | initUpdateChecker() 97 | getInstalledAppNames() 98 | } 99 | 100 | // When a renderer starts, send down all the locally stored data 101 | // for reducer synchronisation. 102 | else if (startedPicker.match(action) || startedPrefs.match(action)) { 103 | dispatch(receivedRendererStartupSignal(nextState)) 104 | } 105 | 106 | // Clicked URL bar 107 | else if (clickedUrlBar.match(action)) { 108 | if (copyUrlToClipboard(nextState.data.url)) { 109 | pickerWindow?.hide() 110 | } 111 | } 112 | 113 | // Set as default browser 114 | else if (clickedSetAsDefaultBrowserButton.match(action)) { 115 | app.setAsDefaultProtocolClient('http') 116 | app.setAsDefaultProtocolClient('https') 117 | } 118 | 119 | // Update and restart 120 | else if (clickedUpdateButton.match(action)) { 121 | autoUpdater.checkForUpdates() 122 | } 123 | 124 | // Update and restart 125 | else if (clickedUpdateRestartButton.match(action)) { 126 | autoUpdater.quitAndInstall() 127 | // removeWindowsFromMemory() 128 | } 129 | 130 | // Rescan for browsers 131 | else if (clickedRescanApps.match(action)) { 132 | getInstalledAppNames() 133 | } 134 | 135 | // Clicked app 136 | else if (clickedApp.match(action)) { 137 | const { appName, isAlt, isShift } = action.payload 138 | 139 | // Ignore if app's bundle id is missing 140 | if (appName) { 141 | openApp(appName, nextState.data.url, isAlt, isShift) 142 | pickerWindow?.hide() 143 | } 144 | } 145 | 146 | // Pressed key in picker window 147 | else if (pressedKey.match(action)) { 148 | // Escape key 149 | if (action.payload.physicalKey === 'Escape') { 150 | pickerWindow?.hide() 151 | } 152 | // Copy key 153 | else if (action.payload.metaKey && action.payload.virtualKey === 'c') { 154 | if (copyUrlToClipboard(nextState.data.url)) { 155 | pickerWindow?.hide() 156 | } 157 | } 158 | // App hotkey 159 | else { 160 | const foundApp = nextState.storage.apps.find( 161 | (storedApp) => storedApp.hotCode === action.payload.physicalKey, 162 | ) 163 | 164 | if (!action.payload.metaKey && foundApp) { 165 | openApp( 166 | foundApp.name, 167 | nextState.data.url, 168 | action.payload.altKey, 169 | action.payload.shiftKey, 170 | ) 171 | pickerWindow?.hide() 172 | } 173 | } 174 | } 175 | 176 | // Open URL 177 | else if (openedUrl.match(action)) { 178 | showPickerWindow() 179 | } 180 | 181 | // Tray: restore picker 182 | else if (clickedRestorePicker.match(action)) { 183 | showPickerWindow() 184 | } 185 | 186 | // Tray: open prefs 187 | else if (clickedOpenPrefs.match(action)) { 188 | showPrefsWindow() 189 | } 190 | 191 | // Open prefs on click update bar 192 | else if (clickedUpdateBar.match(action)) { 193 | pickerWindow?.hide() 194 | showPrefsWindow() 195 | } 196 | 197 | // Open homepage 198 | else if (clickedHomepageButton.match(action)) { 199 | shell.openExternal(B_URL) 200 | } 201 | 202 | // Open issues page 203 | else if (clickedOpenIssueButton.match(action)) { 204 | shell.openExternal(ISSUES_URL) 205 | } 206 | 207 | // Factory reset 208 | else if (confirmedReset.match(action)) { 209 | if (process.env.NODE_ENV === 'development') { 210 | prefsWindow?.hide() 211 | } else { 212 | // removeWindowsFromMemory() 213 | app.relaunch() 214 | app.exit() 215 | } 216 | } 217 | 218 | // Get app icons 219 | else if (retrievedInstalledApps.match(action)) { 220 | getAppIcons(nextState.storage.apps) 221 | } 222 | 223 | return result 224 | } 225 | -------------------------------------------------------------------------------- /src/main/state/middleware.bus.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from '../../shared/state/channels.js' 2 | import type { Middleware } from '../../shared/state/model.js' 3 | import { isFSA } from '../../shared/state/model.js' 4 | import { pickerWindow, prefsWindow } from '../windows.js' 5 | 6 | /** 7 | * Pass actions between main and renderers 8 | */ 9 | export const busMiddleware = (): Middleware => () => (next) => (action) => { 10 | if (!isFSA(action)) return next(action) 11 | 12 | // eslint-disable-next-line n/callback-return -- must flush to get nextState 13 | const result = next(action) 14 | 15 | // Send actions from main to all renderers 16 | if (action.meta?.channel === Channel.MAIN) { 17 | pickerWindow?.webContents.send(Channel.MAIN, action) 18 | prefsWindow?.webContents.send(Channel.MAIN, action) 19 | } 20 | // Send actions from prefs to picker 21 | else if (action.meta?.channel === Channel.PREFS) { 22 | pickerWindow?.webContents.send(Channel.MAIN, action) 23 | } 24 | // Send actions from picker to prefs 25 | else if (action.meta?.channel === Channel.PICKER) { 26 | prefsWindow?.webContents.send(Channel.MAIN, action) 27 | } 28 | 29 | return result 30 | } 31 | -------------------------------------------------------------------------------- /src/main/state/store.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-spread -- see https://redux-toolkit.js.org/api/getDefaultMiddleware#intended-usage */ 2 | 3 | import { configureStore } from '@reduxjs/toolkit' 4 | import { app } from 'electron' 5 | 6 | import { Channel } from '../../shared/state/channels.js' 7 | import { channelInjectorMiddleware } from '../../shared/state/middleware.channel-injector.js' 8 | import { logMiddleware } from '../../shared/state/middleware.log.js' 9 | import { defaultData } from '../../shared/state/reducer.data.js' 10 | import type { RootState } from '../../shared/state/reducer.root.js' 11 | import { rootReducer } from '../../shared/state/reducer.root.js' 12 | import { database } from '../database.js' 13 | import { actionHubMiddleware } from './middleware.action-hub.js' 14 | import { busMiddleware } from './middleware.bus.js' 15 | 16 | const channel = Channel.MAIN 17 | 18 | const preloadedState: RootState = { 19 | data: { 20 | ...defaultData, 21 | isDefaultProtocolClient: app.isDefaultProtocolClient('http'), 22 | version: `${app.getVersion()}${app.isPackaged ? '' : ' DEV'}`, 23 | }, 24 | storage: database.getAll(), 25 | } 26 | 27 | const { dispatch, getState } = configureStore({ 28 | middleware: (getDefaultMiddleware) => 29 | getDefaultMiddleware({ thunk: false }) 30 | .prepend(channelInjectorMiddleware(channel)) 31 | .concat(busMiddleware()) 32 | .concat(actionHubMiddleware()) 33 | .concat(logMiddleware()), 34 | preloadedState, 35 | reducer: rootReducer, 36 | }) 37 | 38 | export { dispatch, getState } 39 | -------------------------------------------------------------------------------- /src/main/tray.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | import { app, Menu, Tray } from 'electron' 5 | 6 | import { clickedOpenPrefs, clickedRestorePicker } from './state/actions.js' 7 | import { dispatch } from './state/store.js' 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 10 | 11 | let tray: Tray | undefined 12 | 13 | /** 14 | * Menubar icon 15 | */ 16 | export function createTray(): void { 17 | tray = new Tray(path.join(__dirname, '/icon/tray_iconTemplate.png')) 18 | 19 | tray.setPressedImage(path.join(__dirname, '/icon/tray_iconHighlight.png')) 20 | 21 | tray.setToolTip('Browserosaurus') 22 | 23 | tray.setContextMenu( 24 | Menu.buildFromTemplate([ 25 | { 26 | click: () => dispatch(clickedRestorePicker()), 27 | label: 'Restore recently closed URL', 28 | }, 29 | { 30 | type: 'separator', 31 | }, 32 | { 33 | click: () => dispatch(clickedOpenPrefs()), 34 | label: 'Preferences...', 35 | }, 36 | { 37 | type: 'separator', 38 | }, 39 | { 40 | click: () => app.exit(), 41 | label: 'Quit', 42 | }, 43 | ]), 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/main/utils/copy-url-to-clipboard.test.ts: -------------------------------------------------------------------------------- 1 | import electron, { clipboard } from 'electron' 2 | 3 | import copyUrlToClipboard from './copy-url-to-clipboard.js' 4 | 5 | test('should copy string', () => { 6 | const notificationSpy = jest.spyOn(electron, 'Notification') 7 | copyUrlToClipboard('string') 8 | 9 | expect(clipboard.readText()).toBe('string') 10 | expect(notificationSpy).toHaveBeenCalledWith({ 11 | body: 'URL copied to clipboard', 12 | silent: true, 13 | title: 'Browserosaurus', 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/main/utils/copy-url-to-clipboard.ts: -------------------------------------------------------------------------------- 1 | import { clipboard, Notification } from 'electron' 2 | 3 | const copyUrlToClipboard = (string: string): boolean => { 4 | if (string) { 5 | clipboard.writeText(string) 6 | new Notification({ 7 | body: 'URL copied to clipboard', 8 | silent: true, 9 | title: 'Browserosaurus', 10 | }).show() 11 | return true 12 | } 13 | 14 | return false 15 | } 16 | 17 | export default copyUrlToClipboard 18 | -------------------------------------------------------------------------------- /src/main/utils/get-app-icons.ts: -------------------------------------------------------------------------------- 1 | import { execFile } from 'node:child_process' 2 | import path from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import { promisify } from 'node:util' 5 | 6 | import log from 'electron-log' 7 | 8 | import type { AppName } from '../../config/apps.js' 9 | import type { Storage } from '../../shared/state/reducer.storage.js' 10 | import { gotAppIcons } from '../state/actions.js' 11 | import { dispatch } from '../state/store.js' 12 | 13 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 14 | 15 | const execFileP = promisify(execFile) 16 | 17 | const binary = path.join( 18 | __dirname, 19 | '..', 20 | '..', 21 | 'node_modules', 22 | 'file-icon', 23 | 'file-icon', 24 | ) 25 | 26 | const HUNDRED_MEGABYTES = 1024 * 1024 * 100 27 | 28 | async function getIconDataURI(file: string, size: number): Promise { 29 | try { 30 | const { stdout: buffer } = await execFileP( 31 | binary, 32 | [JSON.stringify([{ appOrPID: file, size }])], 33 | { encoding: null, maxBuffer: HUNDRED_MEGABYTES }, 34 | ) 35 | 36 | return `data:image/png;base64,${buffer.toString('base64')}` 37 | } catch (error: unknown) { 38 | if (error instanceof Error) { 39 | // eslint-disable-next-line no-console 40 | console.log(`Error reading ${file}`) 41 | } 42 | 43 | throw error 44 | } 45 | } 46 | 47 | export async function getAppIcons(apps: Storage['apps']): Promise { 48 | try { 49 | const icons: Partial> = {} 50 | 51 | for await (const app of apps) { 52 | try { 53 | const dataURI = await getIconDataURI(app.name, 64) 54 | icons[app.name] = dataURI 55 | } catch (error: unknown) { 56 | log.warn(error) 57 | } 58 | } 59 | 60 | dispatch(gotAppIcons(icons)) 61 | } catch (error: unknown) { 62 | log.error(error) 63 | // eslint-disable-next-line no-console 64 | console.error('[getAppIcon error]', error) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/utils/get-installed-app-names.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process' 2 | import path from 'node:path' 3 | 4 | import { sleep } from 'tings' 5 | 6 | import type { AppName } from '../../config/apps.js' 7 | import { apps } from '../../config/apps.js' 8 | import { retrievedInstalledApps, startedScanning } from '../state/actions.js' 9 | import { dispatch } from '../state/store.js' 10 | 11 | function getAllInstalledAppNames(): string[] { 12 | const appNames = execSync( 13 | 'find ~/Applications /Applications -iname "*.app" -prune -not -path "*/.*" 2>/dev/null ||true', 14 | ) 15 | .toString() 16 | .trim() 17 | .split('\n') 18 | .map((appPath) => path.parse(appPath).name) 19 | 20 | return appNames 21 | } 22 | 23 | async function getInstalledAppNames(): Promise { 24 | dispatch(startedScanning()) 25 | 26 | const allInstalledAppNames = getAllInstalledAppNames() 27 | 28 | const installedApps = Object.keys(apps).filter((appName) => 29 | allInstalledAppNames.includes(appName), 30 | ) as AppName[] 31 | 32 | // It appears that sometimes the installed app IDs are not fetched, maybe a 33 | // race with Spotlight index? So if none found, keep retrying. 34 | // TODO is this needed any more, now using we're `find` method? 35 | // https://github.com/will-stone/browserosaurus/issues/425 36 | if (installedApps.length === 0) { 37 | await sleep(500) 38 | getInstalledAppNames() 39 | } else { 40 | dispatch(retrievedInstalledApps(installedApps)) 41 | } 42 | } 43 | 44 | export { getInstalledAppNames } 45 | -------------------------------------------------------------------------------- /src/main/utils/get-update-url.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | 3 | export function getUpdateUrl(): string { 4 | return `https://update.electronjs.org/will-stone/browserosaurus/darwin-${ 5 | process.arch 6 | }/${app.getVersion()}` 7 | } 8 | -------------------------------------------------------------------------------- /src/main/utils/init-update-checker.ts: -------------------------------------------------------------------------------- 1 | import { app, autoUpdater } from 'electron' 2 | 3 | import package_ from '../../../package.json' 4 | import { logger } from '../../shared/utils/logger.js' 5 | import { 6 | availableUpdate, 7 | downloadedUpdate, 8 | downloadingUpdate, 9 | } from '../state/actions.js' 10 | import { dispatch } from '../state/store.js' 11 | import { pickerWindow, prefsWindow } from '../windows.js' 12 | import { getUpdateUrl } from './get-update-url.js' 13 | import { isUpdateAvailable } from './is-update-available.js' 14 | 15 | /** 16 | * Auto update check on production 17 | */ 18 | export async function initUpdateChecker(): Promise { 19 | if (app.isPackaged) { 20 | autoUpdater.setFeedURL({ 21 | headers: { 22 | 'User-Agent': `${package_.name}/${package_.version} (darwin: ${process.arch})`, 23 | }, 24 | url: getUpdateUrl(), 25 | }) 26 | 27 | autoUpdater.on('before-quit-for-update', () => { 28 | // All windows must be closed before an update can be applied using "restart". 29 | pickerWindow?.destroy() 30 | prefsWindow?.destroy() 31 | }) 32 | 33 | autoUpdater.on('update-available', () => { 34 | dispatch(downloadingUpdate()) 35 | }) 36 | 37 | autoUpdater.on('update-downloaded', () => { 38 | dispatch(downloadedUpdate()) 39 | }) 40 | 41 | autoUpdater.on('error', () => { 42 | logger('AutoUpdater', 'An error has occurred') 43 | }) 44 | 45 | // Run on load 46 | if (await isUpdateAvailable()) { 47 | dispatch(availableUpdate()) 48 | } 49 | 50 | // 1000 * 60 * 60 * 24 51 | const ONE_DAY_MS = 86_400_000 52 | 53 | // Check for updates every day. 54 | setInterval(async () => { 55 | if (await isUpdateAvailable()) { 56 | dispatch(availableUpdate()) 57 | } 58 | }, ONE_DAY_MS) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/utils/is-update-available.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { getUpdateUrl } from './get-update-url.js' 4 | 5 | export async function isUpdateAvailable(): Promise { 6 | let isNewVersionAvailable: boolean 7 | 8 | try { 9 | const { data } = await axios(getUpdateUrl()) 10 | isNewVersionAvailable = Boolean(data) 11 | } catch { 12 | isNewVersionAvailable = false 13 | } 14 | 15 | return isNewVersionAvailable 16 | } 17 | -------------------------------------------------------------------------------- /src/main/utils/open-app.ts: -------------------------------------------------------------------------------- 1 | import { execFile } from 'node:child_process' 2 | 3 | import type { AppName } from '../../config/apps.js' 4 | import { apps } from '../../config/apps.js' 5 | 6 | export function openApp( 7 | appName: AppName, 8 | url: string, 9 | isAlt: boolean, 10 | isShift: boolean, 11 | ): void { 12 | const selectedApp = apps[appName] 13 | 14 | const convertedUrl = 15 | 'convertUrl' in selectedApp ? selectedApp.convertUrl(url) : url 16 | 17 | const openArguments: string[] = [ 18 | '-a', 19 | appName, 20 | isAlt ? '--background' : [], 21 | isShift && 'privateArg' in selectedApp 22 | ? ['--new', '--args', selectedApp.privateArg] 23 | : [], 24 | // In order for private/incognito mode to work the URL needs to be passed 25 | // in last, _after_ the respective app.privateArg flag 26 | convertedUrl, 27 | ] 28 | .filter(Boolean) 29 | .flat() 30 | 31 | execFile('open', openArguments) 32 | } 33 | -------------------------------------------------------------------------------- /src/main/utils/remove-windows-from-memory.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { pickerWindow, prefsWindow } from '../windows.js' 3 | 4 | /** 5 | * When exiting the app, the windows must first be removed from memory so that 6 | * any residually running JS does not try to access them, causing a crash. 7 | * https://stackoverflow.com/questions/38309240/object-has-been-destroyed-when-open-secondary-child-window-in-electron-js 8 | */ 9 | export function removeWindowsFromMemory(): void { 10 | // @ts-expect-error -- window must be destroyed to prevent race condition 11 | prefsWindow = null 12 | // @ts-expect-error -- window must be destroyed to prevent race condition 13 | pickerWindow = null 14 | } 15 | -------------------------------------------------------------------------------- /src/main/windows.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | import { app, BrowserWindow, screen } from 'electron' 5 | 6 | import { database } from './database.js' 7 | import { 8 | changedPickerWindowBounds, 9 | gotDefaultBrowserStatus, 10 | } from './state/actions.js' 11 | import { dispatch } from './state/store.js' 12 | 13 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 14 | 15 | declare const PREFS_WINDOW_VITE_DEV_SERVER_URL: string 16 | declare const PICKER_WINDOW_VITE_DEV_SERVER_URL: string 17 | declare const PREFS_WINDOW_VITE_NAME: string 18 | declare const PICKER_WINDOW_VITE_NAME: string 19 | 20 | // Prevents garbage collection 21 | let pickerWindow: BrowserWindow | null | undefined 22 | let prefsWindow: BrowserWindow | null | undefined 23 | 24 | async function createWindows(): Promise { 25 | prefsWindow = new BrowserWindow({ 26 | // Only show on demand 27 | show: false, 28 | 29 | // Chrome 30 | center: true, 31 | fullscreen: false, 32 | fullscreenable: false, 33 | height: 500, 34 | maximizable: false, 35 | minimizable: false, 36 | resizable: false, 37 | titleBarStyle: 'hidden', 38 | transparent: true, 39 | vibrancy: 'window', 40 | width: 600, 41 | 42 | // Meta 43 | icon: path.join(__dirname, '/icon/icon.png'), 44 | title: 'Preferences', 45 | 46 | webPreferences: { 47 | contextIsolation: true, 48 | nodeIntegration: false, 49 | nodeIntegrationInSubFrames: false, 50 | nodeIntegrationInWorker: false, 51 | preload: path.join(__dirname, 'preload.js'), 52 | }, 53 | }) 54 | 55 | prefsWindow.on('hide', () => { 56 | prefsWindow?.hide() 57 | }) 58 | 59 | prefsWindow.on('close', (event_) => { 60 | event_.preventDefault() 61 | prefsWindow?.hide() 62 | }) 63 | 64 | prefsWindow.on('show', () => { 65 | // There isn't a listener for default protocol client, therefore the check 66 | // is made each time the window is brought into focus. 67 | dispatch(gotDefaultBrowserStatus(app.isDefaultProtocolClient('http'))) 68 | }) 69 | 70 | const height = database.get('height') 71 | 72 | pickerWindow = new BrowserWindow({ 73 | alwaysOnTop: true, 74 | center: true, 75 | frame: true, 76 | fullscreen: false, 77 | fullscreenable: false, 78 | hasShadow: true, 79 | height, 80 | icon: path.join(__dirname, '/icon/icon.png'), 81 | maximizable: false, 82 | maxWidth: 250, 83 | minHeight: 112, 84 | minimizable: false, 85 | minWidth: 250, 86 | movable: false, 87 | resizable: true, 88 | show: false, 89 | title: 'Browserosaurus', 90 | titleBarStyle: 'hidden', 91 | transparent: true, 92 | vibrancy: 'popover', 93 | visualEffectState: 'active', 94 | webPreferences: { 95 | contextIsolation: true, 96 | nodeIntegration: false, 97 | nodeIntegrationInSubFrames: false, 98 | nodeIntegrationInWorker: false, 99 | preload: path.join(__dirname, 'preload.js'), 100 | }, 101 | width: 250, 102 | }) 103 | 104 | pickerWindow.setWindowButtonVisibility(false) 105 | 106 | pickerWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) 107 | 108 | pickerWindow.on('hide', () => { 109 | pickerWindow?.hide() 110 | }) 111 | 112 | pickerWindow.on('close', (event_) => { 113 | event_.preventDefault() 114 | pickerWindow?.hide() 115 | }) 116 | 117 | pickerWindow.on('resize', () => { 118 | if (pickerWindow) { 119 | dispatch(changedPickerWindowBounds(pickerWindow.getBounds())) 120 | } 121 | }) 122 | 123 | pickerWindow.on('blur', () => { 124 | pickerWindow?.hide() 125 | }) 126 | 127 | if (PREFS_WINDOW_VITE_DEV_SERVER_URL && PICKER_WINDOW_VITE_DEV_SERVER_URL) { 128 | await Promise.all([ 129 | prefsWindow.loadURL(PREFS_WINDOW_VITE_DEV_SERVER_URL), 130 | pickerWindow.loadURL(PICKER_WINDOW_VITE_DEV_SERVER_URL), 131 | ]) 132 | } else { 133 | await Promise.all([ 134 | prefsWindow.loadFile( 135 | path.join( 136 | __dirname, 137 | `../renderer/${PREFS_WINDOW_VITE_NAME}/index.html`, 138 | ), 139 | ), 140 | pickerWindow.loadFile( 141 | path.join( 142 | __dirname, 143 | `../renderer/${PICKER_WINDOW_VITE_NAME}/index.html`, 144 | ), 145 | ), 146 | ]) 147 | } 148 | } 149 | 150 | function showPickerWindow(): void { 151 | if (pickerWindow) { 152 | const displayBounds = screen.getDisplayNearestPoint( 153 | screen.getCursorScreenPoint(), 154 | ).bounds 155 | 156 | const displayEnd = { 157 | x: displayBounds.x + displayBounds.width, 158 | y: displayBounds.y + displayBounds.height, 159 | } 160 | 161 | const mousePoint = screen.getCursorScreenPoint() 162 | 163 | const bWindowBounds = pickerWindow.getBounds() 164 | 165 | const nudge = { 166 | x: -125, 167 | y: -30, 168 | } 169 | 170 | const inWindowPosition = { 171 | x: 172 | mousePoint.x + bWindowBounds.width + nudge.x > displayEnd.x 173 | ? displayEnd.x - bWindowBounds.width 174 | : mousePoint.x + nudge.x, 175 | y: 176 | mousePoint.y + bWindowBounds.height + nudge.y > displayEnd.y 177 | ? displayEnd.y - bWindowBounds.height 178 | : mousePoint.y + nudge.y, 179 | } 180 | 181 | pickerWindow.setPosition(inWindowPosition.x, inWindowPosition.y, false) 182 | 183 | pickerWindow.show() 184 | } 185 | } 186 | 187 | function showPrefsWindow(): void { 188 | prefsWindow?.show() 189 | } 190 | 191 | export { 192 | createWindows, 193 | pickerWindow, 194 | prefsWindow, 195 | showPickerWindow, 196 | showPrefsWindow, 197 | } 198 | -------------------------------------------------------------------------------- /src/renderers/picker/components/_bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'react-redux' 2 | 3 | import store from '../state/store.js' 4 | import App from './layout.js' 5 | 6 | const Bootstrap: React.FC = () => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | 14 | export default Bootstrap 15 | -------------------------------------------------------------------------------- /src/renderers/picker/components/atoms/app-logo.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | import type { InstalledApp } from '../../../shared/state/hooks.js' 4 | 5 | type Props = React.ComponentPropsWithoutRef<'img'> & { 6 | readonly app: InstalledApp 7 | readonly className?: string 8 | readonly icon: string | undefined 9 | } 10 | 11 | const AppLogo = ({ app, className, icon }: Props): JSX.Element => { 12 | return ( 13 | 19 | ) 20 | } 21 | 22 | export default AppLogo 23 | -------------------------------------------------------------------------------- /src/renderers/picker/components/atoms/kbd.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | type Props = { 4 | readonly children: React.ReactNode 5 | readonly className?: string 6 | readonly style?: React.CSSProperties 7 | } 8 | 9 | const Kbd = ({ children, className, style }: Props): JSX.Element => { 10 | return ( 11 | 19 | {children} 20 | 21 | ) 22 | } 23 | 24 | export default Kbd 25 | -------------------------------------------------------------------------------- /src/renderers/picker/components/hooks/use-keyboard-events.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { pressedKey } from '../../state/actions.js' 5 | 6 | export const useKeyboardEvents = (): void => { 7 | const dispatch = useDispatch() 8 | 9 | useEffect(() => { 10 | const handler = (event: KeyboardEvent) => { 11 | if (['Tab', 'Enter', 'Space'].includes(event.code)) { 12 | return 13 | } 14 | 15 | event.preventDefault() 16 | 17 | dispatch( 18 | pressedKey({ 19 | altKey: event.altKey, 20 | metaKey: event.metaKey, 21 | physicalKey: event.code, 22 | shiftKey: event.shiftKey, 23 | virtualKey: event.key.toLowerCase(), 24 | }), 25 | ) 26 | } 27 | 28 | document.addEventListener('keydown', handler) 29 | 30 | return function cleanup() { 31 | document.removeEventListener('keydown', handler) 32 | } 33 | }, [dispatch]) 34 | } 35 | -------------------------------------------------------------------------------- /src/renderers/picker/components/layout.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { useEffect } from 'react' 3 | import { useDispatch } from 'react-redux' 4 | 5 | import { Spinner } from '../../shared/components/atoms/spinner.js' 6 | import { 7 | useDeepEqualSelector, 8 | useInstalledApps, 9 | useKeyCodeMap, 10 | useSelector, 11 | } from '../../shared/state/hooks.js' 12 | import { appsRef, appsScrollerRef } from '../refs.js' 13 | import { clickedApp, startedPicker } from '../state/actions.js' 14 | import AppLogo from './atoms/app-logo.js' 15 | import Kbd from './atoms/kbd.js' 16 | import { useKeyboardEvents } from './hooks/use-keyboard-events.js' 17 | import SupportMessage from './organisms/support-message.js' 18 | import UpdateBar from './organisms/update-bar.js' 19 | import UrlBar from './organisms/url-bar.js' 20 | 21 | const useAppStarted = () => { 22 | const dispatch = useDispatch() 23 | useEffect(() => { 24 | dispatch(startedPicker()) 25 | }, [dispatch]) 26 | } 27 | 28 | const App: React.FC = () => { 29 | const dispatch = useDispatch() 30 | 31 | /** 32 | * Tell main that renderer is available 33 | */ 34 | useAppStarted() 35 | 36 | /** 37 | * Setup keyboard listeners 38 | */ 39 | useKeyboardEvents() 40 | 41 | const apps = useInstalledApps() 42 | const url = useSelector((state) => state.data.url) 43 | const icons = useDeepEqualSelector((state) => state.data.icons) 44 | 45 | const keyCodeMap = useKeyCodeMap() 46 | 47 | // const totalApps = apps.length 48 | 49 | // useEffect(() => {}, [totalApps]) 50 | 51 | return ( 52 |
56 | {!apps[0] && ( 57 |
58 | 59 |
60 | )} 61 | 62 |
66 | {apps.map((app, index) => { 67 | return ( 68 |
69 | 120 |
121 | ) 122 | })} 123 |
124 | 125 | 126 | 127 | 128 | 129 | 130 |
131 | ) 132 | } 133 | 134 | export default App 135 | -------------------------------------------------------------------------------- /src/renderers/picker/components/organisms/apps.test.tsx: -------------------------------------------------------------------------------- 1 | import '../../../shared/preload' 2 | 3 | import { fireEvent, render, screen } from '@testing-library/react' 4 | import electron from 'electron' 5 | 6 | import { keyLayout } from '../../../../../__fixtures__/key-layout.js' 7 | import { 8 | openedUrl, 9 | receivedRendererStartupSignal, 10 | retrievedInstalledApps, 11 | } from '../../../../main/state/actions.js' 12 | import { Channel } from '../../../../shared/state/channels.js' 13 | import { defaultData } from '../../../../shared/state/reducer.data.js' 14 | import { addChannelToAction } from '../../../../shared/utils/add-channel-to-action.js' 15 | import { reorderedApp } from '../../../prefs/state/actions.js' 16 | import { clickedApp, pressedKey } from '../../state/actions.js' 17 | import Wrapper from '../_bootstrap.js' 18 | 19 | beforeAll(() => { 20 | Object.defineProperty(globalThis.navigator, 'keyboard', { 21 | value: { 22 | getLayoutMap: jest 23 | .fn() 24 | .mockResolvedValue({ entries: jest.fn().mockReturnValue(keyLayout) }), 25 | }, 26 | writable: true, 27 | }) 28 | }) 29 | 30 | test('kitchen sink', async () => { 31 | render() 32 | const win = new electron.BrowserWindow() 33 | await win.webContents.send( 34 | Channel.MAIN, 35 | retrievedInstalledApps(['Firefox', 'Safari', 'Brave Browser']), 36 | ) 37 | 38 | // Check apps and app logos shown 39 | expect(screen.getByTestId('Firefox')).toBeVisible() 40 | expect(screen.getByRole('button', { name: 'Firefox App' })).toBeVisible() 41 | expect(screen.getByTestId('Safari')).toBeVisible() 42 | expect(screen.getByRole('button', { name: 'Safari App' })).toBeVisible() 43 | expect(screen.getByTestId('Brave Browser')).toBeVisible() 44 | expect( 45 | screen.getByRole('button', { name: 'Brave Browser App' }), 46 | ).toBeVisible() 47 | 48 | expect(screen.getAllByRole('button', { name: /[A-z]+ App/u })).toHaveLength(3) 49 | 50 | await win.webContents.send( 51 | Channel.MAIN, 52 | receivedRendererStartupSignal({ 53 | data: { 54 | ...defaultData, 55 | }, 56 | storage: { 57 | apps: [ 58 | { 59 | hotCode: null, 60 | isInstalled: true, 61 | name: 'Firefox', 62 | }, 63 | { 64 | hotCode: null, 65 | isInstalled: true, 66 | name: 'Safari', 67 | }, 68 | { 69 | hotCode: null, 70 | isInstalled: false, 71 | name: 'Opera', 72 | }, 73 | { 74 | hotCode: null, 75 | isInstalled: true, 76 | name: 'Brave Browser', 77 | }, 78 | ], 79 | height: 200, 80 | isSetup: true, 81 | supportMessage: -1, 82 | }, 83 | }), 84 | ) 85 | 86 | expect( 87 | screen.queryByRole('alert', { name: 'Loading browsers' }), 88 | ).not.toBeInTheDocument() 89 | 90 | // Correct info sent to main when app clicked 91 | fireEvent.click(screen.getByRole('button', { name: 'Firefox App' })) 92 | 93 | expect(electron.ipcRenderer.send).toHaveBeenCalledWith( 94 | Channel.PICKER, 95 | addChannelToAction( 96 | clickedApp({ 97 | appName: 'Firefox', 98 | isAlt: false, 99 | isShift: false, 100 | }), 101 | Channel.PICKER, 102 | ), 103 | ) 104 | 105 | // Correct info sent to main when app clicked 106 | const url = 'http://example.com' 107 | await win.webContents.send(Channel.MAIN, openedUrl(url)) 108 | fireEvent.click(screen.getByRole('button', { name: 'Brave Browser App' }), { 109 | altKey: true, 110 | }) 111 | 112 | expect(electron.ipcRenderer.send).toHaveBeenCalledWith( 113 | Channel.PICKER, 114 | addChannelToAction( 115 | clickedApp({ 116 | appName: 'Brave Browser', 117 | isAlt: true, 118 | isShift: false, 119 | }), 120 | Channel.PICKER, 121 | ), 122 | ) 123 | }) 124 | 125 | test('should show spinner when no installed apps are found', async () => { 126 | render() 127 | const win = new electron.BrowserWindow() 128 | await win.webContents.send( 129 | Channel.MAIN, 130 | receivedRendererStartupSignal({ 131 | data: defaultData, 132 | storage: { 133 | apps: [ 134 | { 135 | hotCode: 'KeyS', 136 | isInstalled: false, 137 | name: 'Safari', 138 | }, 139 | ], 140 | height: 200, 141 | isSetup: true, 142 | supportMessage: -1, 143 | }, 144 | }), 145 | ) 146 | 147 | expect(screen.getByRole('alert', { name: 'Loading browsers' })).toBeVisible() 148 | }) 149 | 150 | test('should use hotkey', async () => { 151 | render() 152 | const win = new electron.BrowserWindow() 153 | await win.webContents.send(Channel.MAIN, retrievedInstalledApps(['Safari'])) 154 | await win.webContents.send( 155 | Channel.MAIN, 156 | receivedRendererStartupSignal({ 157 | data: defaultData, 158 | storage: { 159 | apps: [ 160 | { 161 | hotCode: 'KeyS', 162 | isInstalled: true, 163 | name: 'Safari', 164 | }, 165 | ], 166 | height: 200, 167 | isSetup: true, 168 | supportMessage: -1, 169 | }, 170 | }), 171 | ) 172 | 173 | const url = 'http://example.com' 174 | await win.webContents.send(Channel.MAIN, openedUrl(url)) 175 | fireEvent.keyDown(document, { code: 'KeyS', key: 'S', keyCode: 83 }) 176 | 177 | expect(electron.ipcRenderer.send).toHaveBeenCalledWith( 178 | Channel.PICKER, 179 | addChannelToAction( 180 | pressedKey({ 181 | altKey: false, 182 | metaKey: false, 183 | physicalKey: 'KeyS', 184 | shiftKey: false, 185 | virtualKey: 's', 186 | }), 187 | Channel.PICKER, 188 | ), 189 | ) 190 | }) 191 | 192 | test('should use hotkey with alt', async () => { 193 | render() 194 | const win = new electron.BrowserWindow() 195 | await win.webContents.send(Channel.MAIN, retrievedInstalledApps(['Safari'])) 196 | 197 | await win.webContents.send( 198 | Channel.MAIN, 199 | receivedRendererStartupSignal({ 200 | data: defaultData, 201 | storage: { 202 | apps: [ 203 | { 204 | hotCode: 'KeyS', 205 | isInstalled: true, 206 | name: 'Safari', 207 | }, 208 | ], 209 | height: 200, 210 | isSetup: true, 211 | supportMessage: -1, 212 | }, 213 | }), 214 | ) 215 | 216 | const url = 'http://example.com' 217 | await win.webContents.send(Channel.MAIN, openedUrl(url)) 218 | fireEvent.keyDown(document, { 219 | altKey: true, 220 | code: 'KeyS', 221 | key: 's', 222 | keyCode: 83, 223 | }) 224 | 225 | expect(electron.ipcRenderer.send).toHaveBeenCalledWith( 226 | Channel.PICKER, 227 | addChannelToAction( 228 | pressedKey({ 229 | altKey: true, 230 | metaKey: false, 231 | physicalKey: 'KeyS', 232 | shiftKey: false, 233 | virtualKey: 's', 234 | }), 235 | Channel.PICKER, 236 | ), 237 | ) 238 | }) 239 | 240 | test('should hold shift', async () => { 241 | render() 242 | const win = new electron.BrowserWindow() 243 | await win.webContents.send(Channel.MAIN, retrievedInstalledApps(['Firefox'])) 244 | await win.webContents.send(Channel.MAIN, openedUrl('http://example.com')) 245 | fireEvent.click(screen.getByRole('button', { name: 'Firefox App' }), { 246 | shiftKey: true, 247 | }) 248 | 249 | expect(electron.ipcRenderer.send).toHaveBeenCalledWith( 250 | Channel.PICKER, 251 | addChannelToAction( 252 | clickedApp({ 253 | appName: 'Firefox', 254 | isAlt: false, 255 | isShift: true, 256 | }), 257 | Channel.PICKER, 258 | ), 259 | ) 260 | }) 261 | 262 | test('should order tiles', async () => { 263 | render() 264 | 265 | const win = new electron.BrowserWindow() 266 | 267 | await win.webContents.send( 268 | Channel.MAIN, 269 | receivedRendererStartupSignal({ 270 | data: defaultData, 271 | storage: { 272 | apps: [], 273 | height: 200, 274 | isSetup: true, 275 | supportMessage: -1, 276 | }, 277 | }), 278 | ) 279 | 280 | await win.webContents.send( 281 | Channel.MAIN, 282 | retrievedInstalledApps([ 283 | 'Firefox', 284 | 'Safari', 285 | 'Opera', 286 | 'Microsoft Edge', 287 | 'Brave Browser', 288 | ]), 289 | ) 290 | // Check tiles and tile logos shown 291 | const apps = screen.getAllByRole('button', { name: /[A-z]+ App/u }) 292 | 293 | expect(apps).toHaveLength(5) 294 | 295 | await win.webContents.send( 296 | Channel.MAIN, 297 | reorderedApp({ 298 | destinationName: 'Firefox', 299 | sourceName: 'Safari', 300 | }), 301 | ) 302 | await win.webContents.send( 303 | Channel.MAIN, 304 | reorderedApp({ 305 | destinationName: 'Firefox', 306 | sourceName: 'Opera', 307 | }), 308 | ) 309 | await win.webContents.send( 310 | Channel.MAIN, 311 | reorderedApp({ 312 | destinationName: 'Firefox', 313 | sourceName: 'Brave Browser', 314 | }), 315 | ) 316 | 317 | const updatedApps = screen.getAllByRole('button', { name: /[A-z]+ App/u }) 318 | 319 | expect(updatedApps[0]).toHaveAttribute('aria-label', 'Safari App') 320 | expect(updatedApps[1]).toHaveAttribute('aria-label', 'Opera App') 321 | expect(updatedApps[2]).toHaveAttribute('aria-label', 'Brave Browser App') 322 | expect(updatedApps[3]).toHaveAttribute('aria-label', 'Firefox App') 323 | expect(updatedApps[4]).toHaveAttribute('aria-label', 'Microsoft Edge App') 324 | }) 325 | -------------------------------------------------------------------------------- /src/renderers/picker/components/organisms/support-message.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { useIsSupportMessageHidden } from '../../../shared/state/hooks.js' 5 | import { clickedDonate, clickedMaybeLater } from '../../state/actions.js' 6 | 7 | const SupportMessage = (): JSX.Element => { 8 | const dispatch = useDispatch() 9 | const isSupportMessageHidden = useIsSupportMessageHidden() 10 | 11 | return ( 12 |
19 |
20 |

21 | Thank you for downloading Browserosaurus. Please consider supporting 22 | my open source projects. 23 |

24 | 25 |

26 | Thank you 😘 — Will. 27 |

28 | 29 |
30 | 37 | 44 |
45 |
46 |
47 | ) 48 | } 49 | 50 | export default SupportMessage 51 | -------------------------------------------------------------------------------- /src/renderers/picker/components/organisms/update-bar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { useSelector } from '../../../shared/state/hooks.js' 5 | import { clickedUpdateBar } from '../../state/actions.js' 6 | 7 | const UpdateBar: React.FC = () => { 8 | const dispatch = useDispatch() 9 | const updateStatus = useSelector((state) => state.data.updateStatus) 10 | 11 | if (updateStatus !== 'available') { 12 | return null 13 | } 14 | 15 | return ( 16 | 27 | ) 28 | } 29 | 30 | export default UpdateBar 31 | -------------------------------------------------------------------------------- /src/renderers/picker/components/organisms/url-bar.test.tsx: -------------------------------------------------------------------------------- 1 | import '../../../shared/preload' 2 | 3 | import { render, screen } from '@testing-library/react' 4 | import electron from 'electron' 5 | 6 | import { keyLayout } from '../../../../../__fixtures__/key-layout.js' 7 | import { openedUrl } from '../../../../main/state/actions.js' 8 | import { Channel } from '../../../../shared/state/channels.js' 9 | import Wrapper from '../_bootstrap.js' 10 | 11 | beforeAll(() => { 12 | Object.defineProperty(globalThis.navigator, 'keyboard', { 13 | value: { 14 | getLayoutMap: jest 15 | .fn() 16 | .mockResolvedValue({ entries: jest.fn().mockReturnValue(keyLayout) }), 17 | }, 18 | writable: true, 19 | }) 20 | }) 21 | 22 | test('url bar', async () => { 23 | render() 24 | const win = new electron.BrowserWindow() 25 | const protocol = 'http://' 26 | const host = 'example.com' 27 | const port = ':8000' 28 | const rest = '/foo?bar=moo' 29 | const url = `${protocol}${host}${port}${rest}` 30 | await win.webContents.send(Channel.MAIN, openedUrl(url)) 31 | 32 | expect(screen.queryByText(protocol)).not.toBeInTheDocument() 33 | expect(screen.getByText(host + port)).toBeVisible() 34 | expect(screen.queryByText(rest)).not.toBeInTheDocument() 35 | }) 36 | -------------------------------------------------------------------------------- /src/renderers/picker/components/organisms/url-bar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { useSelector } from '../../../shared/state/hooks.js' 5 | import { clickedUrlBar } from '../../state/actions.js' 6 | 7 | type Props = { 8 | className?: string 9 | } 10 | 11 | const UrlBar: React.FC = ({ className }) => { 12 | const dispatch = useDispatch() 13 | const url = useSelector((state) => state.data.url) 14 | 15 | let parsedUrl 16 | 17 | try { 18 | parsedUrl = new URL(url) 19 | } catch { 20 | parsedUrl = { hostname: '', port: '' } 21 | } 22 | 23 | return ( 24 | 43 | ) 44 | } 45 | 46 | export default UrlBar 47 | -------------------------------------------------------------------------------- /src/renderers/picker/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Browserosaurus 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/renderers/picker/index.tsx: -------------------------------------------------------------------------------- 1 | import '../shared/index.css' 2 | 3 | import { createRoot } from 'react-dom/client' 4 | 5 | import Bootstrap from './components/_bootstrap.js' 6 | 7 | const container = document.querySelector('#root') 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 10 | const root = createRoot(container!) 11 | 12 | root.render() 13 | -------------------------------------------------------------------------------- /src/renderers/picker/refs.ts: -------------------------------------------------------------------------------- 1 | import { createRef } from 'react' 2 | 3 | const appsRef: React.MutableRefObject = createRef() 4 | 5 | const appsScrollerRef = createRef() 6 | 7 | export { appsRef, appsScrollerRef } 8 | -------------------------------------------------------------------------------- /src/renderers/picker/state/actions.ts: -------------------------------------------------------------------------------- 1 | import type { AppName } from '../../../config/apps.js' 2 | import { actionNamespacer } from '../../../shared/utils/action-namespacer.js' 3 | 4 | const picker = actionNamespacer('picker') 5 | 6 | type OpenAppArguments = { 7 | appName: AppName | undefined 8 | isAlt: boolean 9 | isShift: boolean 10 | } 11 | 12 | const startedPicker = picker('started') 13 | 14 | const clickedApp = picker('app/clicked') 15 | 16 | const pressedKey = picker<{ 17 | virtualKey: string 18 | physicalKey: string 19 | metaKey: boolean 20 | altKey: boolean 21 | shiftKey: boolean 22 | }>('key/pressed') 23 | 24 | const clickedUrlBar = picker('url-bar/clicked') 25 | const clickedUpdateBar = picker('update-bar/clicked') 26 | 27 | const clickedDonate = picker('donate/clicked') 28 | const clickedMaybeLater = picker('maybe-later/clicked') 29 | 30 | export { 31 | clickedApp, 32 | clickedDonate, 33 | clickedMaybeLater, 34 | clickedUpdateBar, 35 | clickedUrlBar, 36 | pressedKey, 37 | startedPicker, 38 | } 39 | -------------------------------------------------------------------------------- /src/renderers/picker/state/middleware.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-regexp-test */ 2 | import type { Middleware } from 'redux' 3 | 4 | import { 5 | clickedRestorePicker, 6 | openedUrl, 7 | retrievedInstalledApps, 8 | } from '../../../main/state/actions.js' 9 | import { getKeyLayout } from '../../shared/utils/get-key-layout-map.js' 10 | import { appsRef, appsScrollerRef } from '../refs.js' 11 | import { clickedDonate, clickedMaybeLater } from './actions.js' 12 | 13 | /** 14 | * Pass actions between main and renderers 15 | */ 16 | export const pickerMiddleware = 17 | (): Middleware => 18 | ({ dispatch }) => 19 | (next) => 20 | (action) => { 21 | // eslint-disable-next-line n/callback-return -- Move to next middleware 22 | const result = next(action) 23 | 24 | const doesActionOpenPicker = 25 | openedUrl.match(action) || clickedRestorePicker.match(action) 26 | 27 | if ( 28 | doesActionOpenPicker || 29 | retrievedInstalledApps.match(action) || 30 | clickedDonate.match(action) || 31 | clickedMaybeLater.match(action) 32 | ) { 33 | appsRef.current?.[0].focus() 34 | appsScrollerRef.current?.scrollTo({ top: 0 }) 35 | } 36 | 37 | if (doesActionOpenPicker) { 38 | getKeyLayout(dispatch) 39 | } 40 | 41 | return result 42 | } 43 | -------------------------------------------------------------------------------- /src/renderers/picker/state/store.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-spread -- see https://redux-toolkit.js.org/api/getDefaultMiddleware#intended-usage */ 2 | 3 | import type { Action } from '@reduxjs/toolkit' 4 | import { configureStore } from '@reduxjs/toolkit' 5 | 6 | import { Channel } from '../../../shared/state/channels.js' 7 | import { channelInjectorMiddleware } from '../../../shared/state/middleware.channel-injector.js' 8 | import { logMiddleware } from '../../../shared/state/middleware.log.js' 9 | import { rootReducer } from '../../../shared/state/reducer.root.js' 10 | import { busMiddleware } from '../../shared/state/middleware.bus.js' 11 | import { pickerMiddleware } from './middleware.js' 12 | 13 | const store = configureStore({ 14 | middleware: (getDefaultMiddleware) => 15 | getDefaultMiddleware({ thunk: false }) 16 | .prepend(channelInjectorMiddleware(Channel.PICKER)) 17 | .concat(busMiddleware(Channel.PICKER)) 18 | .concat(pickerMiddleware()) 19 | .concat(logMiddleware()), 20 | reducer: rootReducer, 21 | }) 22 | 23 | /** 24 | * Listen for all actions from main 25 | */ 26 | globalThis.electron.receive(Channel.MAIN, (action: Action) => { 27 | store.dispatch(action) 28 | }) 29 | 30 | export default store 31 | -------------------------------------------------------------------------------- /src/renderers/prefs/components/_bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'react-redux' 2 | 3 | import store from '../state/store.js' 4 | import Layout from './layout.js' 5 | 6 | const Bootstrap = (): JSX.Element => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | 14 | export default Bootstrap 15 | -------------------------------------------------------------------------------- /src/renderers/prefs/components/layout.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { startedPrefs } from '../state/actions.js' 5 | import { HeaderBar } from './organisms/header-bar.js' 6 | import { AboutPane } from './organisms/pane-about.js' 7 | import { AppsPane } from './organisms/pane-apps.js' 8 | import { GeneralPane } from './organisms/pane-general.js' 9 | 10 | const useAppStarted = () => { 11 | const dispatch = useDispatch() 12 | useEffect(() => { 13 | dispatch(startedPrefs()) 14 | }, [dispatch]) 15 | } 16 | 17 | const Layout = (): JSX.Element => { 18 | /** 19 | * Tell main that renderer is available 20 | */ 21 | useAppStarted() 22 | 23 | return ( 24 |
25 | 26 |
27 | 28 | 29 | 30 |
31 |
32 | ) 33 | } 34 | 35 | export default Layout 36 | -------------------------------------------------------------------------------- /src/renderers/prefs/components/molecules/pane.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | import type { PrefsTab } from '../../../../shared/state/reducer.data.js' 4 | import { useSelector } from '../../../shared/state/hooks.js' 5 | 6 | type Props = { 7 | readonly children: React.ReactNode 8 | readonly pane: PrefsTab 9 | readonly className?: string 10 | } 11 | 12 | export function Pane({ children, pane, className }: Props): JSX.Element { 13 | const prefsTab = useSelector((state) => state.data.prefsTab) 14 | const isVisible = pane === prefsTab 15 | 16 | return ( 17 |
23 | {children} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/renderers/prefs/components/organisms/header-bar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import type { PrefsTab } from '../../../../shared/state/reducer.data.js' 5 | import { useSelector } from '../../../shared/state/hooks.js' 6 | import { clickedTabButton } from '../../state/actions.js' 7 | 8 | type TabButtonProps = { 9 | readonly tab: PrefsTab 10 | readonly children: string 11 | } 12 | 13 | const TabButton = ({ tab, children }: TabButtonProps) => { 14 | const dispatch = useDispatch() 15 | const prefsTab = useSelector((state) => state.data.prefsTab) 16 | 17 | return ( 18 | 32 | ) 33 | } 34 | 35 | type HeaderBarProps = { 36 | readonly className?: string 37 | } 38 | 39 | export const HeaderBar = ({ className }: HeaderBarProps): JSX.Element => { 40 | return ( 41 |
47 |
48 | Browserosaurus Preferences 49 |
50 |
51 | General 52 | Apps 53 | About 54 |
55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/renderers/prefs/components/organisms/pane-about.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux' 2 | 3 | import icon from '../../../../shared/static/icon/icon.png' 4 | import Button from '../../../shared/components/atoms/button.js' 5 | import { useSelector } from '../../../shared/state/hooks.js' 6 | import { 7 | clickedHomepageButton, 8 | clickedOpenIssueButton, 9 | } from '../../state/actions.js' 10 | import { Pane } from '../molecules/pane.js' 11 | 12 | export const AboutPane = (): JSX.Element => { 13 | const dispatch = useDispatch() 14 | const version = useSelector((state) => state.data.version) 15 | 16 | return ( 17 | 18 |
19 | Logo 20 |

21 | Browserosaurus 22 |

23 |

The browser prompter for macOS

24 |

Version {version}

25 |

Copyright © Will Stone

26 |
27 | 30 | 33 |
34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/renderers/prefs/components/organisms/pane-apps.tsx: -------------------------------------------------------------------------------- 1 | import type { DragEndEvent } from '@dnd-kit/core' 2 | import { 3 | closestCenter, 4 | DndContext, 5 | KeyboardSensor, 6 | PointerSensor, 7 | useSensor, 8 | useSensors, 9 | } from '@dnd-kit/core' 10 | import { 11 | SortableContext, 12 | sortableKeyboardCoordinates, 13 | useSortable, 14 | verticalListSortingStrategy, 15 | } from '@dnd-kit/sortable' 16 | import { CSS } from '@dnd-kit/utilities' 17 | import clsx from 'clsx' 18 | import { useDispatch } from 'react-redux' 19 | 20 | import type { AppName } from '../../../../config/apps.js' 21 | import Input from '../../../shared/components/atoms/input.js' 22 | import { Spinner } from '../../../shared/components/atoms/spinner.js' 23 | import type { InstalledApp } from '../../../shared/state/hooks.js' 24 | import { 25 | useDeepEqualSelector, 26 | useInstalledApps, 27 | useKeyCodeMap, 28 | } from '../../../shared/state/hooks.js' 29 | import { reorderedApp, updatedHotCode } from '../../state/actions.js' 30 | import { Pane } from '../molecules/pane.js' 31 | 32 | type SortableItemProps = { 33 | readonly id: InstalledApp['name'] 34 | readonly name: InstalledApp['name'] 35 | readonly index: number 36 | readonly icon?: string 37 | readonly keyCode?: string 38 | } 39 | 40 | const SortableItem = ({ 41 | id, 42 | name, 43 | keyCode = '', 44 | index, 45 | icon = '', 46 | }: SortableItemProps) => { 47 | const { 48 | attributes, 49 | listeners, 50 | setNodeRef, 51 | transform, 52 | transition, 53 | isDragging, 54 | } = useSortable({ id }) 55 | 56 | const dispatch = useDispatch() 57 | 58 | const style = { 59 | transform: CSS.Transform.toString(transform), 60 | transition, 61 | } 62 | 63 | return ( 64 |
78 |
79 | {index + 1} 80 |
81 |
82 | 87 | {name} 88 |
89 |
90 | event.preventDefault()} 97 | onFocus={(event) => { 98 | event.target.select() 99 | }} 100 | onKeyPress={(event) => { 101 | dispatch( 102 | updatedHotCode({ 103 | appName: id, 104 | value: event.code, 105 | }), 106 | ) 107 | }} 108 | placeholder="Key" 109 | type="text" 110 | value={keyCode} 111 | /> 112 |
113 |
114 | ) 115 | } 116 | 117 | export function AppsPane(): JSX.Element { 118 | const dispatch = useDispatch() 119 | 120 | const installedApps = useInstalledApps().map((installedApp) => ({ 121 | ...installedApp, 122 | id: installedApp.name, 123 | })) 124 | 125 | const sensors = useSensors( 126 | useSensor(PointerSensor), 127 | useSensor(KeyboardSensor, { 128 | coordinateGetter: sortableKeyboardCoordinates, 129 | }), 130 | ) 131 | 132 | const onDragEnd = ({ active, over }: DragEndEvent) => { 133 | if (active.id !== over?.id) { 134 | dispatch( 135 | reorderedApp({ 136 | destinationName: over?.id as AppName, 137 | sourceName: active.id as AppName, 138 | }), 139 | ) 140 | } 141 | } 142 | 143 | const icons = useDeepEqualSelector((state) => state.data.icons) 144 | 145 | const keyCodeMap = useKeyCodeMap() 146 | 147 | return ( 148 | 149 | {installedApps.length === 0 && ( 150 |
151 | 152 |
153 | )} 154 | 155 |
156 | 161 | 165 | {installedApps.map(({ id, name, hotCode }, index) => ( 166 | 174 | ))} 175 | 176 | 177 |
178 | {installedApps.length > 1 && ( 179 |

180 | Drag and drop to sort the list of apps. 181 |

182 | )} 183 |
184 | ) 185 | } 186 | -------------------------------------------------------------------------------- /src/renderers/prefs/components/organisms/pane-general.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux' 2 | 3 | import Button from '../../../shared/components/atoms/button.js' 4 | import { useSelector } from '../../../shared/state/hooks.js' 5 | import { 6 | clickedRescanApps, 7 | clickedSetAsDefaultBrowserButton, 8 | clickedUpdateButton, 9 | clickedUpdateRestartButton, 10 | confirmedReset, 11 | } from '../../state/actions.js' 12 | import { Pane } from '../molecules/pane.js' 13 | 14 | type RowProps = { 15 | readonly children: React.ReactNode 16 | } 17 | 18 | const Row = ({ children }: RowProps): JSX.Element => ( 19 |
{children}
20 | ) 21 | 22 | type LeftProps = { 23 | readonly children: React.ReactNode 24 | } 25 | 26 | const Left = ({ children }: LeftProps): JSX.Element => ( 27 |
{children}
28 | ) 29 | 30 | type RightProps = { 31 | readonly children: React.ReactNode 32 | } 33 | 34 | const Right = ({ children }: RightProps): JSX.Element => ( 35 |
{children}
36 | ) 37 | 38 | export const GeneralPane = (): JSX.Element => { 39 | const dispatch = useDispatch() 40 | 41 | const isDefaultProtocolClient = useSelector( 42 | (state) => state.data.isDefaultProtocolClient, 43 | ) 44 | 45 | const updateStatus = useSelector((state) => state.data.updateStatus) 46 | 47 | const numberOfInstalledApps = useSelector( 48 | (state) => state.storage.apps.filter((app) => app.isInstalled).length, 49 | ) 50 | 51 | return ( 52 | 53 | 54 | Default web browser: 55 | 56 | {isDefaultProtocolClient ? ( 57 | 'Browserosaurus is the default web browser' 58 | ) : ( 59 | 64 | )} 65 |

66 | Setting Browserosaurus as your default web browser means links 67 | clicked outside of web browsers will open the picker window. This is 68 | the primary design of Browserosaurus. However, you can also 69 | programmatically send URLs to Browserosaurus. 70 |

71 |
72 |
73 | 74 | 75 | Find apps: 76 | 77 | 78 |

79 | {numberOfInstalledApps} compatible apps found. Rescan if you have 80 | added or removed a compatible app whilst Browserosaurus is running. 81 |

82 |
83 |
84 | 85 | 86 | Update: 87 | 88 | {updateStatus === 'available' && ( 89 | 92 | )} 93 | {updateStatus === 'downloading' && 'Downloading…'} 94 | {updateStatus === 'downloaded' && ( 95 | 98 | )} 99 | {updateStatus === 'no-update' && 'No update available'} 100 | 101 | 102 | 103 | 104 | Factory Reset: 105 | 106 | 116 |

117 | Restores all preferences to initial defaults and restarts the app as 118 | if run for the first time. 119 |

120 |
121 |
122 |
123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /src/renderers/prefs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Browserosaurus 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/renderers/prefs/index.tsx: -------------------------------------------------------------------------------- 1 | import '../shared/index.css' 2 | 3 | import { createRoot } from 'react-dom/client' 4 | 5 | import Bootstrap from './components/_bootstrap.js' 6 | 7 | const container = document.querySelector('#root') 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 10 | const root = createRoot(container!) 11 | 12 | root.render() 13 | -------------------------------------------------------------------------------- /src/renderers/prefs/state/actions.ts: -------------------------------------------------------------------------------- 1 | import type { AppName } from '../../../config/apps.js' 2 | import type { PrefsTab } from '../../../shared/state/reducer.data.js' 3 | import { actionNamespacer } from '../../../shared/utils/action-namespacer.js' 4 | 5 | const prefs = actionNamespacer('prefs') 6 | 7 | const startedPrefs = prefs('started') 8 | 9 | const clickedTabButton = prefs('tab-button/clicked') 10 | 11 | const clickedSetAsDefaultBrowserButton = prefs( 12 | 'set-as-default-browser-button/clicked', 13 | ) 14 | 15 | const clickedRescanApps = prefs('rescan-apps/clicked') 16 | const clickedUpdateButton = prefs('update-button/clicked') 17 | const clickedUpdateRestartButton = prefs('update-restart-button/clicked') 18 | const confirmedReset = prefs('reset/confirmed') 19 | 20 | const updatedHotCode = prefs<{ appName: AppName; value: string }>( 21 | 'hot-code/updated', 22 | ) 23 | 24 | const reorderedApp = prefs<{ sourceName: AppName; destinationName: AppName }>( 25 | 'app/reordered', 26 | ) 27 | 28 | const clickedHomepageButton = prefs('homepage-button/clicked') 29 | const clickedOpenIssueButton = prefs('open-issue-button/clicked') 30 | 31 | export { 32 | clickedHomepageButton, 33 | clickedOpenIssueButton, 34 | clickedRescanApps, 35 | clickedSetAsDefaultBrowserButton, 36 | clickedTabButton, 37 | clickedUpdateButton, 38 | clickedUpdateRestartButton, 39 | confirmedReset, 40 | reorderedApp, 41 | startedPrefs, 42 | updatedHotCode, 43 | } 44 | -------------------------------------------------------------------------------- /src/renderers/prefs/state/middleware.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-regexp-test */ 2 | import type { Middleware } from 'redux' 3 | 4 | import { clickedOpenPrefs } from '../../../main/state/actions.js' 5 | import { getKeyLayout } from '../../shared/utils/get-key-layout-map.js' 6 | 7 | /** 8 | * Pass actions between main and renderers 9 | */ 10 | export const prefsMiddleware = 11 | (): Middleware => 12 | ({ dispatch }) => 13 | (next) => 14 | (action) => { 15 | // eslint-disable-next-line n/callback-return -- Move to next middleware 16 | const result = next(action) 17 | 18 | if (clickedOpenPrefs.match(action)) { 19 | getKeyLayout(dispatch) 20 | } 21 | 22 | return result 23 | } 24 | -------------------------------------------------------------------------------- /src/renderers/prefs/state/store.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-spread -- see https://redux-toolkit.js.org/api/getDefaultMiddleware#intended-usage */ 2 | 3 | import type { UnknownAction } from '@reduxjs/toolkit' 4 | import { configureStore } from '@reduxjs/toolkit' 5 | 6 | import { Channel } from '../../../shared/state/channels.js' 7 | import { channelInjectorMiddleware } from '../../../shared/state/middleware.channel-injector.js' 8 | import { logMiddleware } from '../../../shared/state/middleware.log.js' 9 | import { rootReducer } from '../../../shared/state/reducer.root.js' 10 | import { busMiddleware } from '../../shared/state/middleware.bus.js' 11 | import { prefsMiddleware } from './middleware.js' 12 | 13 | const store = configureStore({ 14 | middleware: (getDefaultMiddleware) => 15 | getDefaultMiddleware({ thunk: false }) 16 | .prepend(channelInjectorMiddleware(Channel.PREFS)) 17 | .concat(busMiddleware(Channel.PREFS)) 18 | .concat(prefsMiddleware()) 19 | .concat(logMiddleware()), 20 | reducer: rootReducer, 21 | }) 22 | 23 | /** 24 | * Listen for all actions 25 | */ 26 | globalThis.electron.receive(Channel.MAIN, (action: UnknownAction) => { 27 | store.dispatch(action) 28 | }) 29 | 30 | export default store 31 | -------------------------------------------------------------------------------- /src/renderers/shared/components/atoms/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | const Button: React.FC> = ({ 4 | className, 5 | disabled, 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type is hardcoded 7 | type, 8 | ...restProperties 9 | }) => { 10 | return ( 11 |