├── .env.example ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── macos-build.yml │ └── nodejs.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── apps ├── browser-addon │ ├── background.js │ ├── contentScript.js │ ├── icon128.png │ ├── icon16.png │ ├── icon256.png │ ├── icon48.png │ └── manifest.json └── finicky │ ├── .gitignore │ ├── assets │ ├── Info.plist │ └── Resources │ │ └── finicky.icns │ └── src │ ├── assets │ └── assets.go │ ├── browser.go │ ├── browser.h │ ├── browser.m │ ├── browser │ ├── browsers.json │ └── launcher.go │ ├── config │ ├── cache.go │ ├── configfiles.go │ ├── console.go │ └── vm.go │ ├── go.mod │ ├── go.sum │ ├── logger │ └── logger.go │ ├── main.go │ ├── main.h │ ├── main.m │ ├── shorturl │ ├── resolver.go │ └── shortener_domains.json │ ├── util │ ├── info.go │ ├── info.h │ └── info.m │ ├── version │ └── version.go │ └── window │ ├── window.go │ ├── window.h │ └── window.m ├── example-config ├── example.js └── example.ts ├── packages ├── config-api │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── scripts │ │ ├── generate-typedefs.ts │ │ └── tsconfig.json │ ├── src │ │ ├── FinickyURL.test.ts │ │ ├── FinickyURL.ts │ │ ├── config.test.ts │ │ ├── configSchema.ts │ │ ├── index.ts │ │ ├── legacyURLObject.ts │ │ ├── utilities.ts │ │ ├── utils.test.ts │ │ ├── wildcard.test.ts │ │ └── wildcard.ts │ └── tsconfig.json └── finicky-ui │ ├── .gitignore │ ├── .vscode │ └── extensions.json │ ├── README.md │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── finicky-icon.png │ ├── finicky-logo-light.png │ └── finicky-logo.png │ ├── src │ ├── App.svelte │ ├── app.css │ ├── components │ │ ├── About.svelte │ │ ├── DebugMessageToggle.svelte │ │ ├── LogContent.svelte │ │ ├── LogViewer.svelte │ │ ├── StartPage.svelte │ │ ├── TabBar.svelte │ │ └── icons │ │ │ ├── About.svelte │ │ │ ├── General.svelte │ │ │ └── Troubleshoot.svelte │ ├── main.ts │ ├── reset.css │ ├── types.ts │ ├── utils │ │ └── text.ts │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── scripts ├── build.sh ├── gon-config.json ├── install.sh ├── release.sh ├── watch-run.sh └── watch.sh /.env.example: -------------------------------------------------------------------------------- 1 | # Only needed for signing/notarizing the app 2 | AC_USERNAME= 3 | AC_PASSWORD= 4 | AC_PROVIDER= 5 | 6 | # API host to check for updates, not required for local development 7 | API_HOST= -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: johnste # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: johnste # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | custom: # Replace with a single custom sponsorship URL 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Help make Finicky better by reporting problems 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ⚠️ Please note that only Finicky 4 is being actively developed and maintained, bugs in Finicky 3 and below won't be fixed 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **Your configuration** 16 | Please add a link to or paste the relevant parts of your configuration here 17 | 18 | **To Reproduce** 19 | Steps to reproduce the behavior: 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | -------------------------------------------------------------------------------- /.github/workflows/macos-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds the Finicky macOS app for both Silicon (ARM64) and Intel (x86_64) architectures 2 | name: macOS Build 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build-macos: 13 | strategy: 14 | matrix: 15 | include: 16 | - architecture: arm64 17 | display_name: "Apple Silicon (ARM64)" 18 | runner: macos-latest 19 | - architecture: amd64 20 | display_name: "Intel (x86_64)" 21 | runner: macos-13 22 | 23 | runs-on: ${{ matrix.runner }} 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: '22' 33 | cache: 'npm' 34 | cache-dependency-path: | 35 | packages/config-api/package-lock.json 36 | packages/finicky-ui/package-lock.json 37 | 38 | - name: Set up Go 39 | uses: actions/setup-go@v4 40 | with: 41 | go-version: '1.21' 42 | 43 | - name: Install dependencies 44 | run: | 45 | # Make install script executable and run it 46 | chmod +x scripts/install.sh 47 | ./scripts/install.sh 48 | 49 | - name: Build for ${{ matrix.display_name }} 50 | env: 51 | API_HOST: ${{ vars.API_HOST || '' }} 52 | BUILD_TARGET_ARCH: ${{ matrix.architecture }} 53 | run: | 54 | # Create a temporary .env file with the API_HOST variable 55 | echo "API_HOST=${{ vars.API_HOST || '' }}" > .env 56 | 57 | # Make build script executable and run it 58 | chmod +x scripts/build.sh 59 | ./scripts/build.sh 60 | 61 | - name: Create archive 62 | run: | 63 | cd apps/finicky/build 64 | tar -czf Finicky-${{ matrix.architecture }}.tar.gz Finicky-${{ matrix.architecture }}.app 65 | 66 | - name: Upload build artifact 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: Finicky-${{ matrix.architecture }} 70 | path: apps/finicky/build/Finicky-${{ matrix.architecture }}.tar.gz 71 | retention-days: 30 72 | 73 | create-universal-binary: 74 | runs-on: macos-latest 75 | needs: build-macos 76 | 77 | steps: 78 | - name: Checkout code 79 | uses: actions/checkout@v4 80 | 81 | - name: Download ARM64 artifact 82 | uses: actions/download-artifact@v4 83 | with: 84 | name: Finicky-arm64 85 | path: ./artifacts/arm64 86 | 87 | - name: Download AMD64 artifact 88 | uses: actions/download-artifact@v4 89 | with: 90 | name: Finicky-amd64 91 | path: ./artifacts/amd64 92 | 93 | - name: Create universal binary 94 | run: | 95 | # Extract both builds 96 | cd artifacts/arm64 97 | tar -xzf Finicky-arm64.tar.gz 98 | cd ../amd64 99 | tar -xzf Finicky-amd64.tar.gz 100 | cd ../../ 101 | 102 | # Create universal app structure 103 | mkdir -p universal/Finicky.app/Contents/MacOS 104 | cp -r artifacts/arm64/Finicky-arm64.app/Contents/Resources universal/Finicky.app/Contents/ 105 | cp -r artifacts/arm64/Finicky-arm64.app/Contents/Info.plist universal/Finicky.app/Contents/ || true 106 | 107 | # Create universal binary using lipo 108 | lipo -create \ 109 | artifacts/arm64/Finicky-arm64.app/Contents/MacOS/Finicky \ 110 | artifacts/amd64/Finicky-amd64.app/Contents/MacOS/Finicky \ 111 | -output universal/Finicky.app/Contents/MacOS/Finicky 112 | 113 | # Verify the universal binary 114 | lipo -info universal/Finicky.app/Contents/MacOS/Finicky 115 | 116 | # Create archive 117 | cd universal 118 | tar -czf Finicky-universal.tar.gz Finicky.app 119 | 120 | - name: Upload universal binary 121 | uses: actions/upload-artifact@v4 122 | with: 123 | name: Finicky-universal 124 | path: universal/Finicky-universal.tar.gz 125 | retention-days: 14 126 | 127 | sign-and-notarize: 128 | runs-on: macos-latest 129 | needs: create-universal-binary 130 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 131 | 132 | steps: 133 | - name: Checkout code 134 | uses: actions/checkout@v4 135 | 136 | - name: Download universal binary 137 | uses: actions/download-artifact@v4 138 | with: 139 | name: Finicky-universal 140 | path: ./universal 141 | 142 | - name: Extract universal binary 143 | run: | 144 | cd universal 145 | tar -xzf Finicky-universal.tar.gz 146 | 147 | - name: Import signing certificate 148 | env: 149 | SIGNING_CERTIFICATE_P12_DATA: ${{ secrets.SIGNING_CERTIFICATE_P12_DATA }} 150 | SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }} 151 | run: | 152 | # Create temporary keychain 153 | security create-keychain -p temp_password temp.keychain 154 | security default-keychain -s temp.keychain 155 | security unlock-keychain -p temp_password temp.keychain 156 | 157 | # Import certificate 158 | echo "$SIGNING_CERTIFICATE_P12_DATA" | base64 --decode > certificate.p12 159 | security import certificate.p12 -k temp.keychain -P "$SIGNING_CERTIFICATE_PASSWORD" -T /usr/bin/codesign 160 | 161 | # Set partition list 162 | security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k temp_password temp.keychain 163 | 164 | # Clean up 165 | rm certificate.p12 166 | 167 | - name: Install gon 168 | run: | 169 | brew install Bearer/tap/gon 170 | 171 | - name: Update gon config for CI 172 | run: | 173 | # Create the directory structure expected by the existing gon config 174 | mkdir -p apps/finicky/build 175 | # Copy the universal binary to the expected location for the existing gon config 176 | cp -r universal/Finicky.app apps/finicky/build/Finicky.app 177 | 178 | - name: Sign and notarize 179 | env: 180 | AC_USERNAME: ${{ secrets.APPLE_ID_USERNAME }} 181 | AC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 182 | AC_PROVIDER: ${{ secrets.AC_PROVIDER }} 183 | run: | 184 | mkdir -p dist 185 | gon scripts/gon-config.json 186 | 187 | - name: Upload signed DMG 188 | uses: actions/upload-artifact@v4 189 | with: 190 | name: Finicky-signed-dmg 191 | path: dist/Finicky.dmg 192 | retention-days: 30 193 | 194 | - name: Cleanup keychain 195 | if: always() 196 | run: | 197 | security delete-keychain temp.keychain || true 198 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | defaults: 7 | run: 8 | working-directory: ./config-api 9 | on: 10 | push: 11 | branches: [master] 12 | pull_request: 13 | branches: [master] 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | node-version: [20.x] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: yarn --frozen-lockfile 30 | - run: yarn build 31 | - run: yarn test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | build 4 | .env 5 | dist 6 | example-config -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Finicky", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "debug", 9 | "program": "${workspaceFolder}/src", 10 | "args": [], 11 | "env": {}, 12 | "showLog": true 13 | }, 14 | { 15 | "name": "Attach to Finicky", 16 | "type": "go", 17 | "request": "attach", 18 | "mode": "local", 19 | "processId": "${command:pickProcess}" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[json]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2025 John Sterling 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Finicky Logo 4 | 5 |

6 | Always open the right browser
7 |
8 | 9 |
10 | 11 | Finicky is a macOS application that allows you to set up rules that decide which browser is opened for every url. With Finicky as your default browser, you can tell it to open Bluesky or Reddit in one browser, and LinkedIn or Google Meet in another. 12 | 13 | - Route any URL to your preferred browser with powerful matching rules 14 | - Automatically edit URLs before opening them (e.g., force HTTPS, remove tracking parameters) 15 | - Write rules in JavaScript or TypeScript for complete control 16 | - Create complex routing logic with regular expressions and custom functions 17 | - Handle multiple browsers and apps with a single configuration 18 | - Keep your workflow organized by separating work and personal browsing 19 | 20 | [![GitHub prerelease](https://badgen.net/github/release/johnste/finicky?color=purple)](https://GitHub.com/johnste/finicky/releases/) ![MIT License](https://badgen.net/github/license/johnste/finicky) ![Finicky v4 release](https://badgen.net/github/milestones/johnste/finicky/6?color=pink) 21 | 22 | ## Table of Contents 23 | 24 | - [Installation](#installation) 25 | - [Basic configuration](#basic-configuration) 26 | - [Configuration](#documentation) 27 | - [Migrating from Finicky 3](#migrating-from-finicky-3) 28 | 29 | ## Installation 30 | 31 | - Download from [releases](https://github.com/johnste/finicky/releases) 32 | - Or install via homebrew: `brew install --cask finicky` 33 | - Create a JavaScript or TypeScript configuration file at `~/finicky.js`. Have a look at the example configuration below, or in the `example-config` folder. 34 | - Start Finicky (in Applications, or through Spotlight/Alfred/Raycast) and allow it to be set as the default browser. Starting Finicky manually opens the configuration/troubleshooting window. 35 | 36 | ## Basic configuration 37 | 38 | Here's a short example configuration that can help you get started 39 | 40 | ```js 41 | // ~/.finicky.js 42 | export default { 43 | defaultBrowser: "Google Chrome", 44 | rewrite: [ 45 | { 46 | // Redirect all x.com urls to use xcancel.com 47 | match: "x.com/*", 48 | url: (url) => { 49 | url.host = "xcancel.com"; 50 | return url; 51 | }, 52 | }, 53 | ], 54 | handlers: [ 55 | { 56 | // Open all bsky.app urls in Firefox 57 | match: "bsky.app/*", 58 | browser: "Firefox", 59 | }, 60 | { 61 | // Open google.com and *.google.com urls in Google Chrome 62 | match: [ 63 | "google.com/*", // match google.com urls 64 | "*.google.com*", // also match google.com subdomains 65 | ], 66 | browser: "Google Chrome", 67 | }, 68 | ], 69 | }; 70 | ``` 71 | 72 | See the [configuration](#configuration) for all the features Finicky supports. 73 | 74 | ## Configuration 75 | 76 | Finicky has extensive support for matching, rewriting and starting browsers or other application that handle urls. See the wiki for the [full configuration documentation]() explaining available, APIs and options as well as detail information on how to match on urls. 77 | 78 | - The wiki has some good [configuration ideas](https://github.com/johnste/finicky/wiki/Configuration-ideas). 79 | - Visit [discussions](https://github.com/johnste/finicky/discussions) to discuss supporting specific apps. 80 | 81 | ## Migrating from Finicky 3 82 | 83 | Please see the [wiki page](https://github.com/johnste/finicky/wiki/Migrating-from-Finicky-3) for updating info and migrating your configuration 84 | 85 | # Other 86 | 87 | ### Building Finicky from source 88 | 89 | See [Building Finicky from source](https://github.com/johnste/finicky/wiki/Building-Finicky-from-source) 90 | 91 | ### Works well with 92 | 93 | If you are looking for something that lets you pick the browser to activate in a graphical interface, check out [Browserosaurus](https://browserosaurus.com/) by Will Stone, an open source browser prompter for macOS. It works really well together with Finicky! 94 | 95 | ### Questions 96 | 97 | Have any other questions or need help? Please feel free to reach out to me on [Bluesky](https://bsky.app/profile/mejkarsense.se) or post an issue here 98 | 99 | ## License 100 | 101 | [MIT](https://raw.githubusercontent.com/johnste/finicky/master/LICENSE) 102 | 103 | Icon designed by [@uetchy](https://github.com/uetchy) 104 | -------------------------------------------------------------------------------- /apps/browser-addon/background.js: -------------------------------------------------------------------------------- 1 | const menuItemId = "finicky-open-url"; 2 | 3 | let browser = typeof window !== "undefined" && typeof window.browser !== "undefined" ? window.browser : chrome; 4 | 5 | 6 | browser.contextMenus.onClicked.addListener((info, tab) => { 7 | if (info.menuItemId !== menuItemId) { 8 | return; 9 | } 10 | 11 | console.log("Finicky Browser Extension: Opening link in Finicky", info.linkUrl); 12 | 13 | browser.tabs.update(tab.id, { url: "finicky://open/" + btoa(info.linkUrl) }); 14 | }); 15 | 16 | try { 17 | chrome.contextMenus.create({ 18 | id: menuItemId, 19 | title: "Open with Finicky", 20 | contexts: ["link"], 21 | }); 22 | } catch (ex) {} 23 | -------------------------------------------------------------------------------- /apps/browser-addon/contentScript.js: -------------------------------------------------------------------------------- 1 | let isIntercepting = false; 2 | 3 | window.addEventListener("keydown", (event) => { 4 | if (event.key.toLowerCase() === "alt") { 5 | isIntercepting = true; 6 | } 7 | }); 8 | 9 | window.addEventListener("keyup", (event) => { 10 | if (event.key.toLowerCase() === "alt") { 11 | isIntercepting = false; 12 | } 13 | }); 14 | 15 | window.addEventListener("blur", (event) => { 16 | isIntercepting = false; 17 | }); 18 | 19 | function getAnchor(element) { 20 | do { 21 | if (element.tagName?.toLowerCase() === "a") { 22 | return element; 23 | } 24 | 25 | element = element.parentNode; 26 | } while (element.parentNode); 27 | 28 | return undefined; 29 | } 30 | 31 | window.addEventListener( 32 | "mousedown", 33 | function (event) { 34 | const anchor = getAnchor(event.target); 35 | if (!anchor) return; 36 | } 37 | ); 38 | 39 | window.addEventListener( 40 | "click", 41 | function (event) { 42 | const anchor = capture(event); 43 | if (!anchor) return; 44 | 45 | try { 46 | const url = new URL(anchor.href, document.baseURI).href; 47 | window.location = "finicky://open/" + btoa(url); 48 | } catch (ex) { 49 | console.error("Finicky Browser Extension Error", ex); 50 | } 51 | }, 52 | true 53 | ); 54 | 55 | function capture(event) { 56 | if (!isIntercepting) return; 57 | 58 | const anchor = getAnchor(event.target); 59 | 60 | if (!anchor?.hasAttribute("href")) return; 61 | 62 | event.preventDefault(); 63 | event.stopImmediatePropagation(); 64 | 65 | return anchor; 66 | } -------------------------------------------------------------------------------- /apps/browser-addon/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnste/finicky/3163573be455028e735009b55430333d4cba1585/apps/browser-addon/icon128.png -------------------------------------------------------------------------------- /apps/browser-addon/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnste/finicky/3163573be455028e735009b55430333d4cba1585/apps/browser-addon/icon16.png -------------------------------------------------------------------------------- /apps/browser-addon/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnste/finicky/3163573be455028e735009b55430333d4cba1585/apps/browser-addon/icon256.png -------------------------------------------------------------------------------- /apps/browser-addon/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnste/finicky/3163573be455028e735009b55430333d4cba1585/apps/browser-addon/icon48.png -------------------------------------------------------------------------------- /apps/browser-addon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Finicky Browser Addon", 3 | "description": "The Official Finicky Browser Addon. Requires Finicky 4.1+ (macOS only)", 4 | "version": "0.2.0", 5 | "manifest_version": 2, 6 | "homepage_url": "https://github.com/johnste/finicky", 7 | "background": { 8 | "scripts": ["background.js"] 9 | }, 10 | "content_scripts": [ 11 | { 12 | "matches": [""], 13 | "js": ["contentScript.js"] 14 | } 15 | ], 16 | "icons": { 17 | "16": "icon16.png", 18 | "48": "icon48.png", 19 | "128": "icon128.png", 20 | "256": "icon256.png" 21 | }, 22 | "permissions": ["contextMenus", ""] 23 | } 24 | -------------------------------------------------------------------------------- /apps/finicky/.gitignore: -------------------------------------------------------------------------------- 1 | src/assets/templates 2 | src/assets/finickyConfigAPI.js -------------------------------------------------------------------------------- /apps/finicky/assets/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | Finicky 9 | CFBundleExecutable 10 | Finicky 11 | CFBundleIdentifier 12 | se.johnste.finicky 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | Finicky 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 4.1.1 21 | CFBundleVersion 22 | 4.1.1 23 | CFBundleIconFile 24 | finicky.icns 25 | LSUIElement 26 | 0 27 | CFBundleURLTypes 28 | 29 | 30 | CFBundleTypeRole 31 | Viewer 32 | CFBundleURLName 33 | Web site URL 34 | CFBundleURLSchemes 35 | 36 | http 37 | https 38 | finicky 39 | 40 | 41 | 42 | LSMinimumSystemVersion 43 | 12.0 44 | CFBundleDocumentTypes 45 | 46 | 47 | CFBundleTypeIconFile 48 | finicky 49 | CFBundleTypeName 50 | HTML Document 51 | CFBundleTypeRole 52 | Viewer 53 | LSTypeIsPackage 54 | 0 55 | LSItemContentTypes 56 | 57 | public.html 58 | 59 | 60 | 61 | CFBundleTypeIconFile 62 | finicky 63 | CFBundleTypeName 64 | XHTML document 65 | CFBundleTypeRole 66 | Viewer 67 | LSTypeIsPackage 68 | 0 69 | LSItemContentTypes 70 | 71 | public.xhtml 72 | 73 | 74 | 75 | NSUserActivityTypes 76 | 77 | NSUserActivityTypeBrowsingWeb 78 | 79 | 80 | -------------------------------------------------------------------------------- /apps/finicky/assets/Resources/finicky.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnste/finicky/3163573be455028e735009b55430333d4cba1585/apps/finicky/assets/Resources/finicky.icns -------------------------------------------------------------------------------- /apps/finicky/src/assets/assets.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | ) 7 | 8 | //go:embed templates/* 9 | var content embed.FS 10 | 11 | // GetHTML returns the HTML content 12 | func GetHTML() (string, error) { 13 | data, err := content.ReadFile("templates/index.html") 14 | if err != nil { 15 | return "", err 16 | } 17 | return string(data), nil 18 | } 19 | 20 | // GetFile returns the contents of a file from the embedded filesystem 21 | func GetFile(path string) ([]byte, error) { 22 | return content.ReadFile("templates/" + path) 23 | } 24 | 25 | // GetFileSystem returns the embedded file system for direct access 26 | func GetFileSystem() fs.FS { 27 | return content 28 | } 29 | -------------------------------------------------------------------------------- /apps/finicky/src/browser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | #cgo CFLAGS: -x objective-c 5 | #cgo LDFLAGS: -framework Foundation 6 | #include "browser.h" 7 | */ 8 | import "C" 9 | 10 | import ( 11 | "fmt" 12 | "unsafe" 13 | ) 14 | 15 | var bundleId = "se.johnste.finicky" 16 | 17 | func isDefaultBrowser() (bool, error) { 18 | bundleIdHTTP, _ := getDefaultHandlerForURLScheme("http") 19 | bundleIdHTTPS, _ := getDefaultHandlerForURLScheme("https") 20 | bundleIdFinicky, _ := getDefaultHandlerForURLScheme("finicky") 21 | 22 | if bundleIdHTTP == bundleIdHTTPS && 23 | bundleIdHTTP == bundleId && 24 | bundleIdHTTP == bundleIdFinicky { 25 | return true, nil 26 | } 27 | return false, nil 28 | } 29 | 30 | func setDefaultBrowser() (bool, error) { 31 | isDefault, err := isDefaultBrowser() 32 | if err != nil { 33 | return false, err 34 | } 35 | if isDefault { 36 | return true, nil 37 | } 38 | 39 | setDefaultHandlerForURLScheme(bundleId, "http") 40 | setDefaultHandlerForURLScheme(bundleId, "https") 41 | setDefaultHandlerForURLScheme(bundleId, "finicky") 42 | return true, nil 43 | } 44 | 45 | func getDefaultHandlerForURLScheme(scheme string) (string, error) { 46 | // Convert Go string to C string 47 | cScheme := C.CString(scheme) 48 | defer C.free(unsafe.Pointer(cScheme)) 49 | 50 | // Call the Objective-C function from browse.m 51 | result := C.getDefaultHandlerForURLScheme(cScheme) 52 | if result != nil { 53 | defer C.free(unsafe.Pointer(result)) 54 | return C.GoString(result), nil 55 | } else { 56 | return "", fmt.Errorf("no default handler found for '%s'", scheme) 57 | } 58 | } 59 | 60 | func setDefaultHandlerForURLScheme(bundleId string, scheme string) (bool, error) { 61 | // Convert Go string to C string 62 | cScheme := C.CString(scheme) 63 | defer C.free(unsafe.Pointer(cScheme)) 64 | cBundleId := C.CString(bundleId) 65 | defer C.free(unsafe.Pointer(cBundleId)) 66 | 67 | // Call the Objective-C function from browse.m 68 | result := bool(C.setDefaultHandlerForURLScheme(cBundleId, cScheme)) 69 | 70 | return result, nil 71 | } 72 | -------------------------------------------------------------------------------- /apps/finicky/src/browser.h: -------------------------------------------------------------------------------- 1 | // browse.h 2 | #import 3 | #include 4 | 5 | const char* getDefaultHandlerForURLScheme(const char* scheme); 6 | 7 | bool setDefaultHandlerForURLScheme(const char* bundleId, const char* scheme); 8 | -------------------------------------------------------------------------------- /apps/finicky/src/browser.m: -------------------------------------------------------------------------------- 1 | 2 | #include "browser.h" 3 | #import 4 | #import 5 | 6 | const char* getDefaultHandlerForURLScheme(const char* scheme) { 7 | @autoreleasepool { 8 | if (!scheme) return NULL; 9 | 10 | // Convert C string to NSString 11 | NSString *schemeStr = [NSString stringWithUTF8String:scheme]; 12 | if (!schemeStr) return NULL; 13 | 14 | // Create an NSURL with the scheme 15 | NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@://", schemeStr]]; 16 | 17 | // Get the default application URL for the scheme 18 | NSWorkspace *workspace = [NSWorkspace sharedWorkspace]; 19 | NSURL *appURL = [workspace URLForApplicationToOpenURL:url]; 20 | 21 | if (appURL) { 22 | const char *result = [appURL.path UTF8String]; 23 | // NSLog(@"Default application URL for scheme '%@': %@", schemeStr, appURL.path); 24 | // Get the bundle identifier for the application at appURL 25 | NSBundle *appBundle = [NSBundle bundleWithURL:appURL]; 26 | NSString *bundleId = [appBundle bundleIdentifier]; 27 | if (bundleId) { 28 | // NSLog(@"Bundle ID for application: %@", bundleId); 29 | return strdup([bundleId UTF8String]); // Convert NSString to C string and return a copy 30 | } else { 31 | // NSLog(@"Failed to get Bundle ID for application at URL: %@", appURL.path); 32 | } 33 | return NULL; 34 | } 35 | 36 | return NULL; 37 | } 38 | } 39 | 40 | bool setDefaultHandlerForURLScheme(const char* bundleId, const char* scheme) { 41 | @autoreleasepool { 42 | if (!bundleId || !scheme) return false; 43 | 44 | // Convert C strings to NSString 45 | NSString *bundleIdStr = [NSString stringWithUTF8String:bundleId]; 46 | NSString *schemeStr = [NSString stringWithUTF8String:scheme]; 47 | 48 | // Create an NSURL with the scheme 49 | NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@://", schemeStr]]; 50 | 51 | // Get the URL for the application with the given bundle ID 52 | NSWorkspace *workspace = [NSWorkspace sharedWorkspace]; 53 | // NSURL *appURL = [workspace URLForApplicationWithBundleIdentifier:bundleIdStr]; 54 | NSBundle *mainBundle = [NSBundle mainBundle]; 55 | NSURL *appURL = mainBundle.bundleURL; 56 | 57 | if (!appURL) { 58 | NSLog(@"Failed to find application with bundle ID: %@", bundleIdStr); 59 | return false; 60 | } 61 | 62 | // Check if appURL contains ".app" 63 | // if (![appURL.path containsString:@".app"]) { 64 | // NSLog(@"The application URL does not contain '.app': %@", appURL.path); 65 | // return false; 66 | // } 67 | 68 | NSLog(@"Setting default application: %@", appURL); 69 | NSLog(@"Setting default application for scheme: %@", schemeStr); 70 | [workspace setDefaultApplicationAtURL:appURL toOpenURLsWithScheme:schemeStr completionHandler:^(NSError *error) { 71 | if (error) { 72 | NSLog(@"Error setting default handler: %@", error); 73 | } else { 74 | NSLog(@"Successfully set default handler for scheme: %@", schemeStr); 75 | } 76 | }]; 77 | 78 | return true; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apps/finicky/src/browser/browsers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "config_dir_relative": "BraveSoftware/Brave-Browser", 4 | "id": "com.brave.Browser", 5 | "type": "Chromium", 6 | "app_name": "Brave Browser" 7 | }, 8 | { 9 | "config_dir_relative": "Google/Chrome", 10 | "id": "com.google.Chrome", 11 | "type": "Chromium", 12 | "app_name": "Google Chrome" 13 | }, 14 | { 15 | "config_dir_relative": "Google/Chrome Beta", 16 | "id": "com.google.Chrome.beta", 17 | "type": "Chromium", 18 | "app_name": "Google Chrome Beta" 19 | }, 20 | { 21 | "config_dir_relative": "Google/Chrome Canary", 22 | "id": "com.google.Chrome.canary", 23 | "type": "Chromium", 24 | "app_name": "Google Chrome Canary" 25 | }, 26 | { 27 | "config_dir_relative": "Chromium", 28 | "id": "org.chromium.Chromium", 29 | "type": "Chromium", 30 | "app_name": "Chromium" 31 | }, 32 | { 33 | "config_dir_relative": "Microsoft Edge", 34 | "id": "com.microsoft.edgemac", 35 | "type": "Chromium", 36 | "app_name": "Microsoft Edge" 37 | }, 38 | { 39 | "config_dir_relative": "Vivaldi", 40 | "id": "com.vivaldi.Vivaldi", 41 | "type": "Chromium", 42 | "app_name": "Vivaldi" 43 | }, 44 | { 45 | "config_dir_relative": "WaveboxApp", 46 | "id": "com.bookry.wavebox", 47 | "type": "Chromium", 48 | "app_name": "Wavebox" 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /apps/finicky/src/browser/launcher.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | _ "embed" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | 14 | "al.essio.dev/pkg/shellescape" 15 | ) 16 | 17 | //go:embed browsers.json 18 | var browsersJsonData []byte 19 | 20 | type BrowserResult struct { 21 | Browser BrowserConfig `json:"browser"` 22 | Error string `json:"error"` 23 | } 24 | 25 | type BrowserConfig struct { 26 | Name string `json:"name"` 27 | AppType string `json:"appType"` 28 | OpenInBackground *bool `json:"openInBackground"` 29 | Profile string `json:"profile"` 30 | Args []string `json:"args"` 31 | URL string `json:"url"` 32 | } 33 | 34 | type browserInfo struct { 35 | ConfigDirRelative string `json:"config_dir_relative"` 36 | ID string `json:"id"` 37 | AppName string `json:"app_name"` 38 | Type string `json:"type"` 39 | } 40 | 41 | func LaunchBrowser(config BrowserConfig, dryRun bool, openInBackgroundByDefault bool) error { 42 | if config.AppType == "none" { 43 | slog.Info("AppType is 'none', not launching any browser") 44 | return nil 45 | } 46 | 47 | slog.Info("Starting browser", "name", config.Name, "url", config.URL) 48 | 49 | var openArgs []string 50 | 51 | if config.AppType == "bundleId" { 52 | openArgs = []string{"-b", config.Name} 53 | } else { 54 | openArgs = []string{"-a", config.Name} 55 | } 56 | 57 | 58 | var openInBackground bool = openInBackgroundByDefault 59 | 60 | if config.OpenInBackground != nil { 61 | openInBackground = *config.OpenInBackground 62 | } 63 | 64 | if openInBackground { 65 | openArgs = append(openArgs, "-g") 66 | } 67 | 68 | if len(config.Args) == 0 { 69 | 70 | profileArgument, ok := resolveBrowserProfileArgument(config.Name, config.Profile) 71 | if ok { 72 | // FIXME: This is a hack to get the profile argument to work – this won't work for Firefox 73 | openArgs = append(openArgs, "-n") 74 | openArgs = append(openArgs, "--args") 75 | openArgs = append(openArgs, profileArgument) 76 | } 77 | 78 | openArgs = append(openArgs, config.URL) 79 | } else { 80 | openArgs = append(openArgs, config.Args...) 81 | } 82 | 83 | cmd := exec.Command("open", openArgs...) 84 | 85 | // Pretty print the command with proper escaping 86 | prettyCmd := formatCommand(cmd.Path, cmd.Args) 87 | 88 | if dryRun { 89 | slog.Debug("Would run command (dry run)", "command", prettyCmd) 90 | return nil 91 | } else { 92 | slog.Debug("Run command", "command", prettyCmd) 93 | } 94 | 95 | stderr, err := cmd.StderrPipe() 96 | if err != nil { 97 | return err 98 | } 99 | stdout, err := cmd.StdoutPipe() 100 | if err != nil { 101 | return err 102 | } 103 | 104 | if err := cmd.Start(); err != nil { 105 | return err 106 | } 107 | 108 | stderrBytes, err := io.ReadAll(stderr) 109 | if err != nil { 110 | return fmt.Errorf("error reading stderr: %v", err) 111 | } 112 | 113 | stdoutBytes, err := io.ReadAll(stdout) 114 | if err != nil { 115 | return fmt.Errorf("error reading stdout: %v", err) 116 | } 117 | 118 | cmdErr := cmd.Wait() 119 | 120 | if len(stderrBytes) > 0 { 121 | slog.Error("Command returned error", "error", string(stderrBytes)) 122 | } 123 | if len(stdoutBytes) > 0 { 124 | slog.Debug("Command returned output", "output", string(stdoutBytes)) 125 | } 126 | 127 | if cmdErr != nil { 128 | return fmt.Errorf("command failed: %v", cmdErr) 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func resolveBrowserProfileArgument(identifier string, profile string) (string, bool) { 135 | var browsersJson []browserInfo 136 | if err := json.Unmarshal(browsersJsonData, &browsersJson); err != nil { 137 | slog.Info("Error parsing browsers.json", "error", err) 138 | return "", false 139 | } 140 | 141 | // Try to find matching browser by bundle ID 142 | var matchedBrowser *browserInfo 143 | for _, browser := range browsersJson { 144 | if browser.ID == identifier || browser.AppName == identifier { 145 | matchedBrowser = &browser 146 | break 147 | } 148 | } 149 | 150 | if matchedBrowser == nil { 151 | return "", false 152 | } 153 | 154 | slog.Debug("Browser found in browsers.json", "identifier", identifier, "type", matchedBrowser.Type) 155 | 156 | if profile != "" { 157 | switch matchedBrowser.Type { 158 | case "Chromium": 159 | homeDir, err := os.UserHomeDir() 160 | if err != nil { 161 | slog.Info("Error getting home directory", "error", err) 162 | return "", false 163 | } 164 | 165 | localStatePath := filepath.Join(homeDir, "Library/Application Support", matchedBrowser.ConfigDirRelative, "Local State") 166 | profilePath, ok := parseProfiles(localStatePath, profile) 167 | if ok { 168 | return "--profile-directory=" + profilePath, true 169 | } 170 | default: 171 | slog.Info("Browser is not a Chromium browser, skipping profile detection", "identifier", identifier) 172 | } 173 | } 174 | 175 | return "", true 176 | } 177 | 178 | func parseProfiles(localStatePath string, profile string) (string, bool) { 179 | data, err := os.ReadFile(localStatePath) 180 | if err != nil { 181 | slog.Info("Error reading Local State file", "path", localStatePath, "error", err) 182 | return "", false 183 | } 184 | 185 | var localState map[string]interface{} 186 | if err := json.Unmarshal(data, &localState); err != nil { 187 | slog.Info("Error parsing Local State JSON", "error", err) 188 | return "", false 189 | } 190 | 191 | profiles, ok := localState["profile"].(map[string]interface{}) 192 | if !ok { 193 | slog.Info("Could not find profile section in Local State") 194 | return "", false 195 | } 196 | 197 | infoCache, ok := profiles["info_cache"].(map[string]interface{}) 198 | if !ok { 199 | slog.Info("Could not find info_cache in profile section") 200 | return "", false 201 | } 202 | 203 | // Look for the specified profile 204 | for profilePath, info := range infoCache { 205 | profileInfo, ok := info.(map[string]interface{}) 206 | if !ok { 207 | continue 208 | } 209 | 210 | name, ok := profileInfo["name"].(string) 211 | if !ok { 212 | continue 213 | } 214 | 215 | if name == profile { 216 | slog.Info("Found profile by name", "name", name, "path", profilePath) 217 | return profilePath, true 218 | } 219 | } 220 | 221 | // If we didn't find the profile, try to find it by profile folder name 222 | slog.Debug("Could not find profile in browser profiles, trying to find by profile path", "profile", profile) 223 | for profilePath, info := range infoCache { 224 | if profilePath == profile { 225 | // Try to get the profile name of the profile we want the user to use instead 226 | if profileInfo, ok := info.(map[string]interface{}); ok { 227 | if name, ok := profileInfo["name"].(string); ok { 228 | slog.Warn("Found profile using profile path", "path", profilePath, "name", name, "suggestion", "Please use the profile name instead") 229 | } 230 | } 231 | return profilePath, true 232 | } 233 | } 234 | 235 | var profileNames []string 236 | for _, info := range infoCache { 237 | profileInfo, ok := info.(map[string]interface{}) 238 | if !ok { 239 | continue 240 | } 241 | 242 | name, ok := profileInfo["name"].(string) 243 | if !ok { 244 | continue 245 | } 246 | 247 | profileNames = append(profileNames, name) 248 | } 249 | slog.Warn("Could not find profile in browser profiles.", "Expected profile", profile, "Available profiles", strings.Join(profileNames, ", ")) 250 | 251 | return "", false 252 | } 253 | 254 | // formatCommand returns a properly shell-escaped string representation of the command 255 | func formatCommand(path string, args []string) string { 256 | if len(args) == 0 { 257 | return shellescape.Quote(path) 258 | } 259 | 260 | quotedArgs := make([]string, len(args)) 261 | for i, arg := range args { 262 | quotedArgs[i] = shellescape.Quote(arg) 263 | } 264 | 265 | return strings.Join(quotedArgs, " ") 266 | } 267 | -------------------------------------------------------------------------------- /apps/finicky/src/config/cache.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | "finicky/version" 15 | ) 16 | 17 | // CacheData represents the structure for caching bundled configuration files 18 | type CacheData struct { 19 | ConfigPath string `json:"configPath"` 20 | BundlePath string `json:"bundlePath"` 21 | ModTime time.Time `json:"modTime"` 22 | AppVersion string `json:"appVersion"` // Store app version with cache data 23 | } 24 | 25 | // ConfigCache manages persistent caching of bundled configurations 26 | type ConfigCache struct { 27 | cachedBundlePath string 28 | cachedConfigPath string 29 | cachedModTime time.Time 30 | cachePath string // Path to store persistent cache information 31 | appVersion string // The current app version 32 | } 33 | 34 | // NewConfigCache creates a new cache manager 35 | func NewConfigCache() *ConfigCache { 36 | // Get current app version 37 | appVersion := version.GetCurrentVersion() 38 | 39 | // Get the cache file path with version in the name 40 | cachePath := getCachePath("", fmt.Sprintf("config_cache_%s.json", getContentHash(appVersion, 8))) 41 | 42 | cache := &ConfigCache{ 43 | cachePath: cachePath, 44 | appVersion: appVersion, 45 | } 46 | 47 | // Load cache from disk if it exists 48 | cache.loadCache() 49 | 50 | return cache 51 | } 52 | 53 | // GetCachedBundle returns the cached bundle if it exists and is valid 54 | // Returns the bundle path and true if cache hit, empty string and false otherwise 55 | func (cc *ConfigCache) GetCachedBundle(configPath string) (string, bool) { 56 | // Check if we have a cache and that it's for the current version 57 | if cc.cachedBundlePath != "" && cc.cachedConfigPath == configPath { 58 | // Check if file has been modified 59 | fileInfo, err := os.Stat(configPath) 60 | if err == nil && !fileInfo.ModTime().After(cc.cachedModTime) { 61 | // Verify bundled file still exists 62 | if _, err := os.Stat(cc.cachedBundlePath); err == nil { 63 | slog.Debug("Using cached bundled config", "path", strings.Replace(cc.cachedBundlePath, os.Getenv("HOME"), "~", 1), "version", cc.appVersion) 64 | return cc.cachedBundlePath, true 65 | } else { 66 | slog.Debug("Cached bundle file no longer exists, rebundling") 67 | } 68 | } 69 | } 70 | return "", false 71 | } 72 | 73 | // UpdateCache updates the cache with a new bundled file 74 | func (cc *ConfigCache) UpdateCache(configPath, bundlePath string) error { 75 | fileInfo, err := os.Stat(configPath) 76 | if err != nil { 77 | return fmt.Errorf("failed to stat config file: %w", err) 78 | } 79 | 80 | cc.cachedBundlePath = bundlePath 81 | cc.cachedConfigPath = configPath 82 | cc.cachedModTime = fileInfo.ModTime() 83 | 84 | // Save cache to disk for persistence between runs 85 | cc.saveCache() 86 | return nil 87 | } 88 | 89 | // Clear clears the current cache and persists that change to disk 90 | func (cc *ConfigCache) Clear() { 91 | slog.Debug("Clearing configuration cache") 92 | cc.cachedBundlePath = "" 93 | cc.cachedConfigPath = "" 94 | // Update cache file to reflect cleared cache 95 | cc.saveCache() 96 | } 97 | 98 | // loadCache loads the cache information from disk 99 | func (cc *ConfigCache) loadCache() { 100 | // If cache file doesn't exist, just return 101 | if _, err := os.Stat(cc.cachePath); os.IsNotExist(err) { 102 | return 103 | } 104 | 105 | // Read cache file 106 | data, err := os.ReadFile(cc.cachePath) 107 | if err != nil { 108 | slog.Debug("Failed to read cache file", "error", err) 109 | return 110 | } 111 | 112 | // Parse cache data 113 | var cacheData CacheData 114 | if err := json.Unmarshal(data, &cacheData); err != nil { 115 | slog.Debug("Failed to parse cache file", "error", err) 116 | return 117 | } 118 | 119 | // Verify the cache is for the current app version 120 | if cacheData.AppVersion != cc.appVersion { 121 | slog.Debug("Cache is for a different app version, ignoring", 122 | "cacheVersion", cacheData.AppVersion, 123 | "currentVersion", cc.appVersion) 124 | return 125 | } 126 | 127 | // Verify the cache is still valid 128 | if cacheData.BundlePath != "" && cacheData.ConfigPath != "" { 129 | // Check if bundle file exists 130 | if _, err := os.Stat(cacheData.BundlePath); os.IsNotExist(err) { 131 | slog.Debug("Cached bundle file no longer exists", "path", cacheData.BundlePath) 132 | return 133 | } 134 | 135 | // Update cache fields 136 | cc.cachedConfigPath = cacheData.ConfigPath 137 | cc.cachedBundlePath = cacheData.BundlePath 138 | cc.cachedModTime = cacheData.ModTime 139 | slog.Debug("Loaded config cache", 140 | "configPath", strings.Replace(cacheData.ConfigPath, os.Getenv("HOME"), "~", 1), 141 | "bundlePath", strings.Replace(cacheData.BundlePath, os.Getenv("HOME"), "~", 1), 142 | "version", cacheData.AppVersion) 143 | } 144 | } 145 | 146 | // saveCache saves the cache information to disk 147 | func (cc *ConfigCache) saveCache() { 148 | // Skip if no cache data 149 | if cc.cachedConfigPath == "" || cc.cachedBundlePath == "" { 150 | // If cache is empty, try to delete the cache file 151 | if _, err := os.Stat(cc.cachePath); err == nil { 152 | if err := os.Remove(cc.cachePath); err == nil { 153 | slog.Debug("Removed empty cache file", "path", cc.cachePath) 154 | } 155 | } 156 | return 157 | } 158 | 159 | // Create cache data structure 160 | cacheData := CacheData{ 161 | ConfigPath: cc.cachedConfigPath, 162 | BundlePath: cc.cachedBundlePath, 163 | ModTime: cc.cachedModTime, 164 | AppVersion: cc.appVersion, // Include app version in the cache data 165 | } 166 | 167 | // Marshal to JSON 168 | data, err := json.Marshal(cacheData) 169 | if err != nil { 170 | slog.Debug("Failed to marshal cache data", "error", err) 171 | return 172 | } 173 | 174 | // Write to file 175 | err = os.WriteFile(cc.cachePath, data, 0644) 176 | if err != nil { 177 | slog.Debug("Failed to write cache file", "error", err) 178 | return 179 | } 180 | 181 | slog.Debug("Saved config cache", "path", cc.cachePath, "version", cc.appVersion) 182 | } 183 | 184 | // fileInfo represents a file with its path and modification time 185 | type fileInfo struct { 186 | path string 187 | modTime time.Time 188 | } 189 | 190 | // getFilteredFiles returns a list of files in a directory filtered by prefix and suffix, 191 | // sorted by modification time (oldest first) 192 | func getFilteredFiles(dir, prefix, suffix string, excludeFile string) ([]fileInfo, error) { 193 | files, err := os.ReadDir(dir) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | var fileInfos []fileInfo 199 | for _, file := range files { 200 | if strings.HasPrefix(file.Name(), prefix) && strings.HasSuffix(file.Name(), suffix) { 201 | filePath := filepath.Join(dir, file.Name()) 202 | if filePath == excludeFile { 203 | continue // Skip the excluded file 204 | } 205 | 206 | info, err := file.Info() 207 | if err != nil { 208 | continue 209 | } 210 | 211 | fileInfos = append(fileInfos, fileInfo{ 212 | path: filePath, 213 | modTime: info.ModTime(), 214 | }) 215 | } 216 | } 217 | 218 | // Sort by modification time (oldest first) 219 | sort.Slice(fileInfos, func(i, j int) bool { 220 | return fileInfos[i].modTime.Before(fileInfos[j].modTime) 221 | }) 222 | 223 | return fileInfos, nil 224 | } 225 | 226 | // CleanupOldFiles removes old cache files to prevent disk space issues 227 | func CleanupOldFiles(subDir string, currentFile string) { 228 | cacheDir := getFinickyCacheDir() 229 | if subDir != "" { 230 | cacheDir = filepath.Join(cacheDir, subDir) 231 | } 232 | 233 | // Determine the file prefix based on the subdirectory 234 | prefix := "finicky_babel_" 235 | if subDir == "" { 236 | prefix = "finicky_" 237 | } 238 | 239 | // Get app version for logging 240 | appVersion := version.GetCurrentVersion() 241 | 242 | // Get filtered and sorted list of files 243 | fileInfos, err := getFilteredFiles(cacheDir, prefix, ".js", currentFile) 244 | if err != nil { 245 | slog.Debug("Failed to read cache directory for cleanup", "error", err) 246 | return 247 | } 248 | 249 | slog.Debug("Cleaning up old cache files", "count", len(fileInfos), "version", appVersion) 250 | 251 | // Keep only the latest 5 files (including current) 252 | if len(fileInfos) <= 4 { // We're excluding current, so 4 others + current = 5 253 | return 254 | } 255 | 256 | // Delete all but the 4 newest files (plus the current one makes 5) 257 | for i := 0; i < len(fileInfos)-4; i++ { 258 | err := os.Remove(fileInfos[i].path) 259 | if err != nil { 260 | slog.Debug("Failed to remove old cache file", "path", fileInfos[i].path, "error", err) 261 | } else { 262 | slog.Debug("Removed old cache file", "path", fileInfos[i].path) 263 | } 264 | } 265 | } 266 | 267 | // getFinickyCacheDir returns the path to the finicky cache directory, 268 | // creating it if it doesn't exist. 269 | func getFinickyCacheDir() string { 270 | // Get cache directory in user's cache directory 271 | cacheDir, err := os.UserCacheDir() 272 | if err != nil { 273 | slog.Debug("Could not get user cache directory", "error", err) 274 | cacheDir = os.TempDir() 275 | } 276 | 277 | finickyCacheDir := filepath.Join(cacheDir, "Finicky") 278 | err = os.MkdirAll(finickyCacheDir, 0755) 279 | if err != nil { 280 | slog.Debug("Could not create finicky cache directory", "error", err) 281 | } 282 | 283 | return finickyCacheDir 284 | } 285 | 286 | // getCachePath returns a path within the finicky cache directory with optional subdirectories 287 | func getCachePath(subDir string, fileName string) string { 288 | cacheDir := getFinickyCacheDir() 289 | 290 | if subDir != "" { 291 | cacheDir = filepath.Join(cacheDir, subDir) 292 | err := os.MkdirAll(cacheDir, 0755) 293 | if err != nil { 294 | slog.Debug("Failed to create cache subdirectory", "dir", subDir, "error", err) 295 | // Fallback to main cache dir 296 | cacheDir = getFinickyCacheDir() 297 | } 298 | } 299 | 300 | return filepath.Join(cacheDir, fileName) 301 | } 302 | 303 | // getContentHash generates a hash for the given content 304 | func getContentHash(content string, length int) string { 305 | if length <= 0 { 306 | length = 8 // Default length 307 | } 308 | hash := fmt.Sprintf("%x", sha256.Sum256([]byte(content))) 309 | if len(hash) > length { 310 | hash = hash[:length] 311 | } 312 | return hash 313 | } 314 | 315 | // GetTransformedPath returns a deterministic path for a transformed file 316 | func GetTransformedPath(content string) string { 317 | appVersion := version.GetCurrentVersion() 318 | 319 | // Combine content and app version for the hash 320 | contentWithVersion := content + "|version:" + appVersion 321 | contentHash := getContentHash(contentWithVersion, 12) 322 | return getCachePath("transform", fmt.Sprintf("finicky_babel_%s.js", contentHash)) 323 | } 324 | 325 | // GetBundlePath returns a deterministic path for a bundled file 326 | func GetBundlePath(configPath string) string { 327 | appVersion := version.GetCurrentVersion() 328 | 329 | // Combine config path and app version for the hash 330 | pathWithVersion := configPath + "|version:" + appVersion 331 | configHash := getContentHash(pathWithVersion, 8) 332 | return getCachePath("", fmt.Sprintf("finicky_bundle_%s.js", configHash)) 333 | } 334 | -------------------------------------------------------------------------------- /apps/finicky/src/config/configfiles.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/evanw/esbuild/pkg/api" 13 | "github.com/fsnotify/fsnotify" 14 | babel "github.com/jvatic/goja-babel" 15 | ) 16 | 17 | // ConfigFileWatcher handles watching configuration files for changes 18 | type ConfigFileWatcher struct { 19 | watcher *fsnotify.Watcher 20 | customConfigPath string 21 | namespace string 22 | configChangeNotify chan struct{} 23 | 24 | // Cache manager 25 | cache *ConfigCache 26 | } 27 | 28 | // NewConfigFileWatcher creates a new file watcher for configuration files 29 | func NewConfigFileWatcher(customConfigPath string, namespace string, configChangeNotify chan struct{}) (*ConfigFileWatcher, error) { 30 | watcher, err := fsnotify.NewWatcher() 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | cfw := &ConfigFileWatcher{ 36 | watcher: watcher, 37 | customConfigPath: customConfigPath, 38 | namespace: namespace, 39 | configChangeNotify: configChangeNotify, 40 | cache: NewConfigCache(), 41 | } 42 | 43 | go cfw.StartWatching() 44 | 45 | return cfw, nil 46 | } 47 | 48 | // TearDown closes the file watcher 49 | func (cfw *ConfigFileWatcher) TearDown() { 50 | if cfw.watcher != nil { 51 | cfw.watcher.Close() 52 | } 53 | } 54 | 55 | // GetConfigPaths returns a list of potential configuration file paths 56 | func (cfw *ConfigFileWatcher) GetConfigPaths() []string { 57 | var configPaths []string 58 | 59 | if cfw.customConfigPath != "" { 60 | configPaths = append(configPaths, cfw.customConfigPath) 61 | } else { 62 | configPaths = append(configPaths, 63 | "$HOME/.finicky.js", 64 | "$HOME/.finicky.ts", 65 | "$HOME/.config/finicky.js", 66 | "$HOME/.config/finicky.ts", 67 | "$HOME/.config/finicky/finicky.js", 68 | "$HOME/.config/finicky/finicky.ts", 69 | ) 70 | } 71 | 72 | for i, path := range configPaths { 73 | configPaths[i] = os.ExpandEnv(path) 74 | } 75 | 76 | return configPaths 77 | } 78 | 79 | // GetConfigPath returns the path to an existing configuration file 80 | func (cfw *ConfigFileWatcher) GetConfigPath(log bool) (string, error) { 81 | configPaths := cfw.GetConfigPaths() 82 | 83 | for _, path := range configPaths { 84 | if _, err := os.Stat(path); err == nil { 85 | if log { 86 | slog.Info("Using config file", "path", strings.Replace(path, os.Getenv("HOME"), "~", 1)) 87 | } 88 | return path, nil 89 | } 90 | } 91 | if cfw.customConfigPath != "" { 92 | return "", fmt.Errorf("no config file found at %s", cfw.customConfigPath) 93 | } 94 | return "", fmt.Errorf("no config file found in any of these locations: %s", strings.Join(configPaths, ", ")) 95 | } 96 | 97 | func (cfw *ConfigFileWatcher) BundleConfig() (string, string, error) { 98 | configPath, err := cfw.GetConfigPath(true) 99 | 100 | if configPath == "" || err != nil { 101 | return "", "", err 102 | } 103 | 104 | // Check if we can use cached bundle 105 | if bundlePath, cacheHit := cfw.cache.GetCachedBundle(configPath); cacheHit { 106 | return bundlePath, configPath, nil 107 | } 108 | 109 | // Apply babel transformation 110 | transformedPath, err := cfw.babelTransform(configPath) 111 | if err != nil { 112 | return "", configPath, err 113 | } 114 | 115 | slog.Debug("Bundling config") 116 | 117 | // Use a deterministic filename to help with caching 118 | bundlePath := GetBundlePath(transformedPath) 119 | 120 | result := api.Build(api.BuildOptions{ 121 | EntryPoints: []string{transformedPath}, 122 | Outfile: bundlePath, 123 | Bundle: true, 124 | Write: true, 125 | LogLevel: api.LogLevelError, 126 | Platform: api.PlatformNeutral, 127 | Target: api.ES2015, 128 | Format: api.FormatIIFE, 129 | GlobalName: cfw.namespace, 130 | Loader: map[string]api.Loader{ 131 | ".ts.symlink": api.LoaderTS, 132 | ".js.symlink": api.LoaderJS, 133 | }, 134 | }) 135 | 136 | if len(result.Errors) > 0 { 137 | var errorTexts []string 138 | for _, err := range result.Errors { 139 | errorTexts = append(errorTexts, err.Text) 140 | } 141 | return "", configPath, fmt.Errorf("build errors: %s", strings.Join(errorTexts, ", ")) 142 | } 143 | 144 | // Update cache 145 | originalConfigPath, err := cfw.GetConfigPath(false) 146 | if err == nil { 147 | cfw.cache.UpdateCache(originalConfigPath, bundlePath) 148 | } 149 | 150 | return bundlePath, configPath, nil 151 | } 152 | 153 | func (cfw *ConfigFileWatcher) babelTransform(configPath string) (string, error) { 154 | startTime := time.Now() 155 | slog.Debug("Transforming config with babel") 156 | 157 | // Check if we need to transform (only if it's a .js or .mjs file) 158 | ext := filepath.Ext(configPath) 159 | if ext != ".js" && ext != ".mjs" { 160 | slog.Debug("Skipping babel transform for non-JS file", "path", configPath) 161 | return configPath, nil 162 | } 163 | 164 | configBytes, err := os.ReadFile(configPath) 165 | if err != nil { 166 | return "", fmt.Errorf("error reading config file: %w", err) 167 | } 168 | configString := string(configBytes) 169 | 170 | babel.Init(1) // Setup transformers (can be any number > 0) 171 | res, err := babel.Transform(strings.NewReader(configString), map[string]interface{}{ 172 | "plugins": []string{ 173 | "transform-named-capturing-groups-regex", 174 | }, 175 | }) 176 | 177 | if err != nil { 178 | return "", err 179 | } 180 | 181 | resBytes, err := io.ReadAll(res) 182 | if err != nil { 183 | return "", err 184 | } 185 | resString := string(resBytes) 186 | 187 | // Get a deterministic path for the transformed file 188 | transformedPath := GetTransformedPath(configString) 189 | 190 | // Check if transformed file already exists 191 | if _, err := os.Stat(transformedPath); err == nil { 192 | slog.Debug("Using existing transformed file", "path", transformedPath) 193 | return transformedPath, nil 194 | } 195 | 196 | // Write to the persistent location 197 | err = os.WriteFile(transformedPath, []byte(resString), 0644) 198 | if err != nil { 199 | return "", fmt.Errorf("error writing to transform file: %w", err) 200 | } 201 | 202 | slog.Debug("Saved babel output", "path", transformedPath) 203 | slog.Debug("Babel transform complete", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) 204 | 205 | // Clean up old transformed files 206 | CleanupOldFiles("transform", transformedPath) 207 | 208 | return transformedPath, nil 209 | } 210 | 211 | func (cfw *ConfigFileWatcher) StartWatching() error { 212 | for { 213 | configPath, err := cfw.GetConfigPath(false) 214 | 215 | if err != nil { 216 | // Watch any potential config paths 217 | configPaths := cfw.GetConfigPaths() 218 | 219 | // Use a map to track unique folders 220 | uniqueFolders := make(map[string]bool) 221 | for _, path := range configPaths { 222 | expandedPath := os.ExpandEnv(path) 223 | folder := filepath.Dir(expandedPath) 224 | uniqueFolders[folder] = true 225 | } 226 | 227 | // Convert to slice for logging 228 | var watchPaths []string 229 | for folder := range uniqueFolders { 230 | watchPaths = append(watchPaths, folder) 231 | } 232 | 233 | slog.Debug("Watching for config files", "paths", watchPaths) 234 | 235 | // Add each unique folder to the watcher 236 | for folder := range uniqueFolders { 237 | if err := cfw.watcher.Add(folder); err != nil { 238 | slog.Debug("Error watching folder", "folder", folder, "error", err) 239 | } 240 | } 241 | 242 | detectedCreation := false 243 | for !detectedCreation { 244 | select { 245 | case event, ok := <-cfw.watcher.Events: 246 | if !ok { 247 | return fmt.Errorf("watcher closed") 248 | } 249 | 250 | if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) { 251 | // Check if the event path matches any of our config paths 252 | eventName := event.Name 253 | isConfigFile := false 254 | for _, path := range configPaths { 255 | expandedPath := os.ExpandEnv(path) 256 | if eventName == expandedPath { 257 | isConfigFile = true 258 | break 259 | } 260 | } 261 | 262 | if !isConfigFile { 263 | break 264 | } 265 | 266 | detectedCreation = true 267 | 268 | err := cfw.handleConfigFileEvent(event) 269 | if err != nil { 270 | return err 271 | } 272 | 273 | for folder := range uniqueFolders { 274 | if err := cfw.watcher.Remove(folder); err != nil { 275 | slog.Debug("Error removing watch on folder", "folder", folder, "error", err) 276 | } 277 | } 278 | } 279 | 280 | case err, ok := <-cfw.watcher.Errors: 281 | if !ok { 282 | return fmt.Errorf("watcher closed") 283 | } 284 | slog.Debug("error:", "error", err) 285 | } 286 | } 287 | 288 | } else { 289 | slog.Debug("Watching config file", "path", strings.Replace(configPath, os.Getenv("HOME"), "~", 1)) 290 | 291 | cfw.watcher.Add(configPath) 292 | 293 | select { 294 | case event, ok := <-cfw.watcher.Events: 295 | if !ok { 296 | return fmt.Errorf("watcher closed") 297 | } 298 | err := cfw.handleConfigFileEvent(event) 299 | if err != nil { 300 | return err 301 | } 302 | case err, ok := <-cfw.watcher.Errors: 303 | if !ok { 304 | return fmt.Errorf("watcher closed") 305 | } 306 | slog.Debug("error:", "error", err) 307 | } 308 | } 309 | } 310 | // Unreachable - infinite loop above. Added for completeness only. 311 | // return nil 312 | } 313 | 314 | // handleConfigFileEvent processes configuration file events and takes appropriate actions 315 | // Returns an error if the configuration file was removed 316 | func (cfw *ConfigFileWatcher) handleConfigFileEvent(event fsnotify.Event) error { 317 | if event.Has(fsnotify.Create) { 318 | slog.Debug("Configuration file created", "path", event.Name) 319 | } 320 | 321 | if event.Has(fsnotify.Write) { 322 | slog.Debug("Configuration file changed", "path", event.Name) 323 | // Clear the cache when config changes 324 | cfw.cache.Clear() 325 | } 326 | 327 | if event.Has(fsnotify.Remove) { 328 | slog.Debug("Configuration file removed", "path", event.Name) 329 | // Clear the cache when config is removed 330 | cfw.cache.Clear() 331 | cfw.configChangeNotify <- struct{}{} 332 | return fmt.Errorf("configuration file removed") 333 | } 334 | 335 | // Add a small delay to avoid rapid reloading 336 | time.Sleep(500 * time.Millisecond) 337 | cfw.configChangeNotify <- struct{}{} 338 | return nil 339 | } 340 | -------------------------------------------------------------------------------- /apps/finicky/src/config/console.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/dop251/goja" 10 | ) 11 | 12 | func GetConsoleMap() map[string]interface{} { 13 | logFunction := func(level string) func(call goja.FunctionCall) goja.Value { 14 | return func(call goja.FunctionCall) goja.Value { 15 | var args []string 16 | for _, arg := range call.Arguments { 17 | if arg.ExportType() == nil { 18 | args = append(args, arg.String()) 19 | continue 20 | } 21 | 22 | if arg.ExportType() == nil || arg.ExportType().Kind() == reflect.Map || arg.ExportType().Kind() == reflect.Struct || arg.ExportType().Kind() == reflect.Ptr { 23 | jsonString, err := json.MarshalIndent(arg.Export(), "", " ") 24 | if err != nil { 25 | args = append(args, arg.String()) 26 | } else { 27 | args = append(args, string(jsonString)) 28 | } 29 | } else { 30 | args = append(args, arg.String()) 31 | } 32 | } 33 | switch level { 34 | case "error": 35 | slog.Error(strings.Join(args, " ")) 36 | case "warn": 37 | slog.Warn(strings.Join(args, " ")) 38 | default: 39 | slog.Info(strings.Join(args, " ")) 40 | } 41 | return goja.Undefined() 42 | } 43 | } 44 | 45 | return map[string]interface{}{ 46 | "log": logFunction("info"), 47 | "error": logFunction("error"), 48 | "warn": logFunction("warn"), 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/finicky/src/config/vm.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "embed" 5 | "finicky/util" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | 10 | "github.com/dop251/goja" 11 | ) 12 | 13 | type VM struct { 14 | runtime *goja.Runtime 15 | namespace string 16 | } 17 | 18 | // ConfigState represents the current state of the configuration 19 | type ConfigState struct { 20 | Handlers int16 `json:"handlers"` 21 | Rewrites int16 `json:"rewrites"` 22 | DefaultBrowser string `json:"defaultBrowser"` 23 | } 24 | 25 | func New(embeddedFiles embed.FS, namespace string, bundlePath string) (*VM, error) { 26 | vm := &VM{ 27 | runtime: goja.New(), 28 | namespace: namespace, 29 | } 30 | 31 | err := vm.setup(embeddedFiles, bundlePath) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return vm, nil 37 | } 38 | 39 | func (vm *VM) setup(embeddedFiles embed.FS, bundlePath string) error { 40 | apiContent, err := embeddedFiles.ReadFile("assets/finickyConfigAPI.js") 41 | if err != nil { 42 | return fmt.Errorf("failed to read bundled file: %v", err) 43 | } 44 | 45 | var content []byte 46 | if bundlePath != "" { 47 | content, err = os.ReadFile(bundlePath) 48 | if err != nil { 49 | return fmt.Errorf("failed to read file: %v", err) 50 | } 51 | } 52 | 53 | vm.runtime.Set("self", vm.runtime.GlobalObject()) 54 | vm.runtime.Set("console", GetConsoleMap()) 55 | 56 | slog.Debug("Evaluating API script...") 57 | if _, err = vm.runtime.RunString(string(apiContent)); err != nil { 58 | return fmt.Errorf("failed to run api script: %v", err) 59 | } 60 | slog.Debug("Done evaluating API script") 61 | 62 | userAPI := vm.runtime.Get("finickyConfigAPI").ToObject(vm.runtime).Get("utilities").ToObject(vm.runtime) 63 | finicky := make(map[string]interface{}) 64 | for _, key := range userAPI.Keys() { 65 | finicky[key] = userAPI.Get(key) 66 | } 67 | 68 | vm.runtime.Set("finicky", finicky) 69 | 70 | if content != nil { 71 | if _, err = vm.runtime.RunString(string(content)); err != nil { 72 | return fmt.Errorf("error while running config script: %v", err) 73 | } 74 | } else { 75 | vm.runtime.Set(vm.namespace, map[string]interface{}{}) 76 | } 77 | 78 | vm.runtime.Set("namespace", vm.namespace) 79 | finalConfig, err := vm.runtime.RunString("finickyConfigAPI.getConfiguration(namespace)") 80 | if err != nil { 81 | return fmt.Errorf("failed to get merged config: %v", err) 82 | } 83 | 84 | vm.runtime.Set("finalConfig", finalConfig) 85 | 86 | validConfig, err := vm.runtime.RunString("finickyConfigAPI.validateConfig(finalConfig)") 87 | if err != nil { 88 | return fmt.Errorf("failed to validate config: %v", err) 89 | } 90 | if !validConfig.ToBoolean() { 91 | return fmt.Errorf("configuration is invalid") 92 | } 93 | 94 | // Set system-specific functions 95 | vm.SetModifierKeysFunc(util.GetModifierKeys) 96 | vm.SetSystemInfoFunc(util.GetSystemInfo) 97 | vm.SetPowerInfoFunc(util.GetPowerInfo) 98 | vm.SetIsAppRunningFunc(util.IsAppRunning) 99 | 100 | return nil 101 | } 102 | 103 | func (vm *VM) ShouldLogToFile(hasError bool) bool { 104 | 105 | logRequests := vm.runtime.ToValue(hasError) 106 | 107 | if !hasError { 108 | var err error 109 | logRequests, err = vm.runtime.RunString("finickyConfigAPI.getOption('logRequests', finalConfig)") 110 | if err != nil { 111 | slog.Warn("Failed to get logRequests option", "error", err) 112 | logRequests = vm.runtime.ToValue(true) 113 | } 114 | } 115 | 116 | return logRequests.ToBoolean() 117 | } 118 | 119 | func (vm *VM) GetConfigState() *ConfigState { 120 | state, err := vm.runtime.RunString("finickyConfigAPI.getConfigState(finalConfig)") 121 | if err != nil { 122 | slog.Error("Failed to get config state", "error", err) 123 | return nil 124 | } 125 | 126 | // Convert the JavaScript object to a Go struct 127 | stateObj := state.ToObject(vm.runtime) 128 | 129 | // Extract values from the JavaScript object 130 | handlers := stateObj.Get("handlers").ToInteger() 131 | rewrites := stateObj.Get("rewrites").ToInteger() 132 | defaultBrowser := stateObj.Get("defaultBrowser").String() 133 | 134 | return &ConfigState{ 135 | Handlers: int16(handlers), 136 | Rewrites: int16(rewrites), 137 | DefaultBrowser: defaultBrowser, 138 | } 139 | } 140 | 141 | // Runtime returns the underlying goja.Runtime 142 | func (vm *VM) Runtime() *goja.Runtime { 143 | return vm.runtime 144 | } 145 | 146 | // SetModifierKeysFunc sets the getModifierKeys function in the VM 147 | func (vm *VM) SetModifierKeysFunc(fn func() map[string]bool) { 148 | finicky := vm.runtime.Get("finicky").ToObject(vm.runtime) 149 | finicky.Set("getModifierKeys", fn) 150 | } 151 | 152 | // SetSystemInfoFunc sets the getSystemInfo function in the VM 153 | func (vm *VM) SetSystemInfoFunc(fn func() map[string]string) { 154 | finicky := vm.runtime.Get("finicky").ToObject(vm.runtime) 155 | finicky.Set("getSystemInfo", fn) 156 | } 157 | 158 | // SetPowerInfoFunc sets the getPowerInfo function in the VM 159 | func (vm *VM) SetPowerInfoFunc(fn func() map[string]interface{}) { 160 | finicky := vm.runtime.Get("finicky").ToObject(vm.runtime) 161 | finicky.Set("getPowerInfo", fn) 162 | } 163 | 164 | // SetIsAppRunningFunc sets the isAppRunning function in the VM 165 | func (vm *VM) SetIsAppRunningFunc(fn func(string) bool) { 166 | finicky := vm.runtime.Get("finicky").ToObject(vm.runtime) 167 | finicky.Set("isAppRunning", fn) 168 | } 169 | -------------------------------------------------------------------------------- /apps/finicky/src/go.mod: -------------------------------------------------------------------------------- 1 | module finicky 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | al.essio.dev/pkg/shellescape v1.6.0 7 | github.com/dop251/goja v0.0.0-20250307175808-203961f822d6 8 | github.com/evanw/esbuild v0.24.2 9 | github.com/fsnotify/fsnotify v1.8.0 10 | github.com/jvatic/goja-babel v0.0.0-20250308121736-c08d87dbdc10 11 | ) 12 | 13 | require ( 14 | github.com/dlclark/regexp2 v1.11.5 // indirect 15 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect 16 | github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect 17 | golang.org/x/sys v0.13.0 // indirect 18 | golang.org/x/text v0.23.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /apps/finicky/src/go.sum: -------------------------------------------------------------------------------- 1 | al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= 2 | al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 4 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 5 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 6 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 7 | github.com/dop251/goja v0.0.0-20250307175808-203961f822d6 h1:G73yPVwEaihFs6WYKFFfSstwNY2vENyECvRnR0tye0g= 8 | github.com/dop251/goja v0.0.0-20250307175808-203961f822d6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= 9 | github.com/evanw/esbuild v0.24.2 h1:PQExybVBrjHjN6/JJiShRGIXh1hWVm6NepVnhZhrt0A= 10 | github.com/evanw/esbuild v0.24.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= 11 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 12 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 13 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= 14 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 15 | github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro= 16 | github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 17 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 18 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 19 | github.com/jvatic/goja-babel v0.0.0-20250308121736-c08d87dbdc10 h1:vWIOMaPN3MJ9W2U+0sKPoouDoSdRP6TNEfmfmT8AmfA= 20 | github.com/jvatic/goja-babel v0.0.0-20250308121736-c08d87dbdc10/go.mod h1:zDU18A3osPvkOEpVRV2TAclAXKauNajDTPhyxqTcQO8= 21 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 22 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 23 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 24 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 25 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 26 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 27 | github.com/stvp/assert v0.0.0-20170616060220-4bc16443988b h1:GlTM/aMVIwU3luIuSN2SIVRuTqGPt1P97YxAi514ulw= 28 | github.com/stvp/assert v0.0.0-20170616060220-4bc16443988b/go.mod h1:CC7OXV9IjEZRA+znA6/Kz5vbSwh69QioernOHeDCatU= 29 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 31 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 33 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 34 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 35 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 36 | -------------------------------------------------------------------------------- /apps/finicky/src/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bytes" 5 | "finicky/window" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | var memLog *bytes.Buffer 16 | var file *os.File 17 | 18 | // windowWriter implements io.Writer to send logs to the window 19 | type windowWriter struct{} 20 | 21 | func (w *windowWriter) Write(p []byte) (n int, err error) { 22 | // Remove trailing newline if present 23 | msg := string(p) 24 | if len(msg) > 0 && msg[len(msg)-1] == '\n' { 25 | msg = msg[:len(msg)-1] 26 | } 27 | 28 | window.SendMessageToWebView("log", msg) 29 | return len(p), nil 30 | } 31 | 32 | // createHandler creates a slog handler with the given writer 33 | func createHandler(writer io.Writer) slog.Handler { 34 | return slog.NewJSONHandler(writer, &slog.HandlerOptions{ 35 | Level: slog.LevelDebug, 36 | AddSource: false, 37 | ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { 38 | // Format time as ISO string with microseconds 39 | if a.Key == slog.TimeKey { 40 | return slog.Attr{ 41 | Key: slog.TimeKey, 42 | Value: slog.StringValue(a.Value.Time().Format("2006-01-02 15:04:05.000000")), 43 | } 44 | } 45 | return a 46 | }, 47 | }) 48 | } 49 | 50 | // Setup initializes the logger with basic configuration 51 | func Setup() { 52 | // Start with in-memory logging 53 | memLog = &bytes.Buffer{} 54 | multiWriter := io.MultiWriter(memLog, os.Stdout, &windowWriter{}) 55 | 56 | // Set the default logger 57 | slog.SetDefault(slog.New(createHandler(multiWriter))) 58 | } 59 | 60 | // SetupFile configures file logging if enabled 61 | func SetupFile(shouldLog bool) error { 62 | slog.Debug("Setting up file logging", "shouldLog", shouldLog) 63 | if shouldLog { 64 | slog.Warn("Logging requests to disk. Logs may include sensitive information. Disable this by setting logRequests: false.") 65 | } 66 | 67 | if !shouldLog { 68 | // FIXME: We should recreate the logger if the file logging options has been changed while the app is running 69 | return nil 70 | } 71 | 72 | homeDir, err := os.UserHomeDir() 73 | if err != nil { 74 | return fmt.Errorf("failed to get user home directory: %v", err) 75 | } 76 | 77 | logDir := filepath.Join(homeDir, "Library", "Logs", "Finicky") 78 | err = os.MkdirAll(logDir, 0755) // Create directory if it doesn't exist 79 | if err != nil { 80 | return fmt.Errorf("failed to create log directory: %v", err) 81 | } 82 | 83 | currentTime := time.Now().Format("2006-01-02_15-04-05.000") 84 | logFile := filepath.Join(logDir, fmt.Sprintf("Finicky_%s.log", currentTime)) 85 | 86 | file, err = os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 87 | if err != nil { 88 | return fmt.Errorf("failed to open log file: %v", err) 89 | } 90 | 91 | slog.Info("Log file created", "path", strings.Replace(logFile, os.Getenv("HOME"), "~", 1)) 92 | 93 | // Write buffered logs to file 94 | if _, err := file.Write(memLog.Bytes()); err != nil { 95 | return fmt.Errorf("failed to write buffered logs: %v", err) 96 | } 97 | 98 | // Update writer to include file while preserving window writer 99 | multiWriter := io.MultiWriter(file, memLog, os.Stdout, &windowWriter{}) 100 | 101 | // Update the default logger 102 | slog.SetDefault(slog.New(createHandler(multiWriter))) 103 | return nil 104 | } 105 | 106 | // Close properly closes the logger and any open file handles 107 | func Close() { 108 | slog.Info("Application closed!") 109 | if file != nil { 110 | file.Close() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /apps/finicky/src/main.h: -------------------------------------------------------------------------------- 1 | // browse.h 2 | #ifndef MAIN_H 3 | #define MAIN_H 4 | 5 | #ifdef __OBJC__ 6 | #import 7 | #endif 8 | 9 | #include 10 | #include 11 | 12 | extern void HandleURL(char *url, char *name, char *bundleId, char *path); 13 | extern void QueueWindowDisplay(int launchedByUser, int openInBackground); 14 | 15 | #ifdef __OBJC__ 16 | @interface BrowseAppDelegate: NSObject 17 | @property (nonatomic) BOOL forceOpenWindow; 18 | @property (nonatomic) BOOL receivedURL; 19 | - (instancetype)initWithForceOpenWindow:(BOOL)forceOpenWindow; 20 | - (void)handleGetURLEvent:(NSAppleEventDescriptor *) event withReplyEvent:(NSAppleEventDescriptor *)replyEvent; 21 | - (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename; 22 | @end 23 | #endif 24 | 25 | void RunApp(int forceOpenWindow); 26 | 27 | #endif /* MAIN_H */ 28 | -------------------------------------------------------------------------------- /apps/finicky/src/main.m: -------------------------------------------------------------------------------- 1 | #include "main.h" 2 | #include "util/info.h" 3 | #import 4 | #import 5 | #import 6 | 7 | @implementation BrowseAppDelegate 8 | 9 | - (instancetype)initWithForceOpenWindow:(BOOL)forceOpenWindow { 10 | self = [super init]; 11 | if (self) { 12 | _forceOpenWindow = forceOpenWindow; 13 | _receivedURL = false; 14 | } 15 | return self; 16 | } 17 | 18 | - (void)applicationDidFinishLaunching:(NSNotification *)notification { 19 | NSDictionary *dict = [notification userInfo]; 20 | 21 | BOOL openInBackground = ![NSApp isActive]; 22 | BOOL openWindow = self.forceOpenWindow; 23 | 24 | if (!openWindow) { 25 | // Even if we aren't forcing the window to open, we still want to open it if didn't receive a URL 26 | openWindow = !self.receivedURL; 27 | } 28 | 29 | NSLog(@"Madeleine openWindow: %d openInBackground: %d", openWindow, openInBackground); 30 | QueueWindowDisplay(openWindow, openInBackground); 31 | } 32 | 33 | - (void)applicationWillFinishLaunching:(NSNotification *)aNotification 34 | { 35 | NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager]; 36 | [appleEventManager setEventHandler:self 37 | andSelector:@selector(handleGetURLEvent:withReplyEvent:) 38 | forEventClass:kInternetEventClass andEventID:kAEGetURL]; 39 | } 40 | 41 | - (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename { 42 | NSLog(@"Opening file: %@", filename); 43 | 44 | // Convert the file path to a file:// URL 45 | NSURL *fileURL = [NSURL fileURLWithPath:filename]; 46 | NSString *urlString = [fileURL absoluteString]; 47 | 48 | // Handle the file URL the same way we handle other URLs 49 | HandleURL((char*)[urlString UTF8String], NULL, NULL, NULL); 50 | 51 | return YES; 52 | } 53 | 54 | - (void)handleGetURLEvent:(NSAppleEventDescriptor *)event 55 | withReplyEvent:(NSAppleEventDescriptor *)replyEvent { 56 | 57 | // Get the application that opened the URL, if available 58 | int32_t pid = [[event attributeDescriptorForKeyword:keySenderPIDAttr] int32Value]; 59 | NSRunningApplication *application = [NSRunningApplication runningApplicationWithProcessIdentifier:pid]; 60 | const char *url = [[[event paramDescriptorForKeyword:keyDirectObject] stringValue] UTF8String]; 61 | const char *name = NULL; 62 | const char *bundleId = NULL; 63 | const char *path = NULL; 64 | 65 | // If we recieve a url, we default to not showing the app in the dock 66 | [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; 67 | self.receivedURL = true; 68 | 69 | if (application) { 70 | NSString *appName = [application localizedName]; 71 | NSString *appBundleID = [application bundleIdentifier]; 72 | NSString *appPath = [[application bundleURL] path]; 73 | 74 | name = [appName UTF8String]; 75 | bundleId = [appBundleID UTF8String]; 76 | path = [appPath UTF8String]; 77 | } else { 78 | NSLog(@"No running application found with PID: %d", pid); 79 | } 80 | 81 | HandleURL((char*)url, (char*)name, (char*)bundleId, (char*)path); 82 | } 83 | 84 | - (BOOL)application:(NSApplication *)application willContinueUserActivityWithType:(NSString *)userActivityType { 85 | return [userActivityType isEqualToString:NSUserActivityTypeBrowsingWeb]; 86 | } 87 | 88 | - (BOOL)application:(NSApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray> * _Nullable))restorationHandler { 89 | if (![userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { 90 | return NO; 91 | } 92 | 93 | NSURL *url = userActivity.webpageURL; 94 | if (!url) { 95 | return NO; 96 | } 97 | 98 | HandleURL((char*)[[url absoluteString] UTF8String], NULL, NULL, NULL); 99 | return YES; 100 | } 101 | 102 | - (void)application:(NSApplication *)application didFailToContinueUserActivityWithType:(NSString *)userActivityType error:(NSError *)error { 103 | // Handle failure if needed 104 | } 105 | 106 | @end 107 | 108 | void RunApp(int forceOpenWindow) { 109 | @autoreleasepool { 110 | // Initialize on the main thread directly, not async 111 | [NSApplication sharedApplication]; 112 | [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; 113 | 114 | BrowseAppDelegate *app = [[BrowseAppDelegate alloc] initWithForceOpenWindow:forceOpenWindow]; 115 | [NSApp setDelegate:app]; 116 | 117 | [NSApp finishLaunching]; 118 | [NSApp run]; 119 | } 120 | } 121 | 122 | -------------------------------------------------------------------------------- /apps/finicky/src/shorturl/resolver.go: -------------------------------------------------------------------------------- 1 | package shorturl 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | //go:embed shortener_domains.json 15 | var shortenerDomainsFS embed.FS 16 | 17 | // Common URL shortener domains 18 | var shortenerDomains []string 19 | 20 | func init() { 21 | // Load shortener domains from embedded JSON file 22 | data, err := shortenerDomainsFS.ReadFile("shortener_domains.json") 23 | if err != nil { 24 | slog.Error("Failed to read shortener domains file", "error", err) 25 | // Fallback to empty list if file cannot be read 26 | shortenerDomains = []string{} 27 | return 28 | } 29 | 30 | if err := json.Unmarshal(data, &shortenerDomains); err != nil { 31 | slog.Error("Failed to parse shortener domains JSON", "error", err) 32 | // Fallback to empty list if JSON is invalid 33 | shortenerDomains = []string{} 34 | } 35 | } 36 | 37 | // ResolveURL resolves a potentially shortened URL to its final destination by following HTTP redirects, so 38 | // the matcher can match the final URL instead of the short URL. 39 | func ResolveURL(originalURL string) (string, error) { 40 | parsedURL, err := url.Parse(originalURL) 41 | if err != nil { 42 | return originalURL, fmt.Errorf("failed to parse URL: %v", err) 43 | } 44 | 45 | // Check if the domain is a known URL shortener 46 | isShortURL := false 47 | for _, domain := range shortenerDomains { 48 | if strings.HasSuffix(parsedURL.Host, domain) { 49 | isShortURL = true 50 | break 51 | } 52 | } 53 | 54 | if !isShortURL { 55 | return originalURL, nil 56 | } 57 | 58 | slog.Debug("URL host looks like a short URL", "host", parsedURL.Host) 59 | 60 | // Create a client with a timeout 61 | client := &http.Client{ 62 | Timeout: 500 * time.Millisecond, 63 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 64 | slog.Debug("Redirected to", "url", req.URL.String()) 65 | // Allow up to 3 redirects 66 | if len(via) >= 3 { 67 | return fmt.Errorf("stopped after 3 redirects") 68 | } 69 | return nil 70 | }, 71 | } 72 | 73 | // Make a HEAD request first to follow redirects without downloading content 74 | req, err := http.NewRequest("HEAD", originalURL, nil) 75 | if err != nil { 76 | return originalURL, fmt.Errorf("failed to create request: %v", err) 77 | } 78 | req.Header.Set("User-Agent", "Finicky/4.0") 79 | 80 | resp, err := client.Do(req) 81 | if err != nil { 82 | slog.Debug("Failed to make HEAD request", "url", originalURL, "error", err) 83 | } 84 | 85 | if resp != nil { 86 | // If we got a successful response, return the final URL 87 | if resp.StatusCode == http.StatusOK { 88 | slog.Debug("Got a successful response", "url", resp.Request.URL.String()) 89 | return resp.Request.URL.String(), nil 90 | } 91 | 92 | if resp.Body != nil { 93 | resp.Body.Close() 94 | } 95 | } 96 | 97 | // If HEAD request failed, try GET as fallback 98 | req, err = http.NewRequest("GET", originalURL, nil) 99 | if err != nil { 100 | return originalURL, fmt.Errorf("failed to create GET request: %v", err) 101 | } 102 | req.Header.Set("User-Agent", "Finicky/4.0") 103 | 104 | resp, err = client.Do(req) 105 | if err != nil { 106 | return originalURL, fmt.Errorf("failed to make GET request: %v", err) 107 | } 108 | 109 | if resp != nil { 110 | if resp.StatusCode == http.StatusOK { 111 | slog.Debug("Got a successful response", "url", resp.Request.URL.String()) 112 | return resp.Request.URL.String(), nil 113 | } 114 | 115 | if resp.Body != nil { 116 | resp.Body.Close() 117 | } 118 | } 119 | 120 | // If both HEAD and GET failed, return original URL 121 | return originalURL, fmt.Errorf("failed to resolve URL: no response received") 122 | 123 | } 124 | -------------------------------------------------------------------------------- /apps/finicky/src/shorturl/shortener_domains.json: -------------------------------------------------------------------------------- 1 | [ 2 | "bit.ly", 3 | "buff.ly", 4 | "d.to", 5 | "dub.sh", 6 | "goo.gl", 7 | "is.gd", 8 | "msteams.link", 9 | "ow.ly", 10 | "shorturl.at", 11 | "spoti.fi", 12 | "t.co", 13 | "tiny.cc", 14 | "tinyurl.com", 15 | "urlshortener.teams.microsoft.com", 16 | "wu8.in" 17 | ] 18 | -------------------------------------------------------------------------------- /apps/finicky/src/util/info.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | /* 4 | #cgo CFLAGS: -x objective-c 5 | #cgo LDFLAGS: -framework Cocoa -framework CoreServices -framework IOKit 6 | #include 7 | #include "info.h" 8 | // PowerInfo struct and getPowerInfo function for power status 9 | */ 10 | import "C" 11 | import ( 12 | "log/slog" 13 | "unsafe" 14 | ) 15 | 16 | // GetModifierKeys returns the current state of modifier keys 17 | func GetModifierKeys() map[string]bool { 18 | keys := C.getModifierKeys() 19 | result := map[string]bool{ 20 | "shift": bool(keys.shift), 21 | "option": bool(keys.option), 22 | "command": bool(keys.command), 23 | "control": bool(keys.control), 24 | "capsLock": bool(keys.capsLock), 25 | "fn": bool(keys.fn), 26 | "function": bool(keys.fn), 27 | } 28 | args := []any{} 29 | for k, v := range result { 30 | if k == "function" { 31 | continue 32 | } 33 | args = append(args, k, v) 34 | } 35 | slog.Debug("Modifier keys state", args...) 36 | return result 37 | } 38 | 39 | // GetSystemInfo returns system information 40 | func GetSystemInfo() map[string]string { 41 | info := C.getSystemInfo() 42 | return map[string]string{ 43 | "localizedName": C.GoString(info.localizedName), 44 | "name": C.GoString(info.name), 45 | } 46 | } 47 | 48 | // GetPowerInfo returns power and battery status information 49 | func GetPowerInfo() map[string]interface{} { 50 | info := C.getPowerInfo() 51 | 52 | percentage := int(info.percentage) 53 | 54 | if percentage == -1 { 55 | slog.Debug("Power info", "isCharging", info.isCharging, "isConnected", info.isConnected, "percentage", nil) 56 | return map[string]interface{}{ 57 | "isCharging": bool(info.isCharging), 58 | "isConnected": bool(info.isConnected), 59 | "percentage": nil, 60 | } 61 | } 62 | 63 | slog.Debug("Power info", "isCharging", info.isCharging, "isConnected", info.isConnected, "percentage", info.percentage) 64 | return map[string]interface{}{ 65 | "isCharging": bool(info.isCharging), 66 | "isConnected": bool(info.isConnected), 67 | "percentage": percentage, 68 | } 69 | } 70 | 71 | // IsAppRunning checks if an app with the given identifier (bundle ID or app name) is running 72 | func IsAppRunning(identifier string) bool { 73 | cIdentifier := C.CString(identifier) 74 | defer C.free(unsafe.Pointer(cIdentifier)) 75 | 76 | isRunning := bool(C.isAppRunning(cIdentifier)) 77 | slog.Debug("App running info", "identifier", identifier, "isRunning", isRunning) 78 | return isRunning 79 | } 80 | -------------------------------------------------------------------------------- /apps/finicky/src/util/info.h: -------------------------------------------------------------------------------- 1 | #ifndef INFO_H 2 | #define INFO_H 3 | 4 | #include 5 | 6 | typedef struct { 7 | bool shift; 8 | bool option; 9 | bool command; 10 | bool control; 11 | bool capsLock; 12 | bool fn; 13 | } ModifierKeys; 14 | 15 | typedef struct { 16 | const char* localizedName; 17 | const char* name; 18 | } SystemInfo; 19 | 20 | typedef struct { 21 | bool isConnected; 22 | bool isCharging; 23 | int percentage; 24 | } PowerInfo; 25 | 26 | ModifierKeys getModifierKeys(void); 27 | SystemInfo getSystemInfo(void); 28 | PowerInfo getPowerInfo(void); 29 | _Bool isAppRunning(const char* identifier); 30 | 31 | #endif /* INFO_H */ -------------------------------------------------------------------------------- /apps/finicky/src/util/info.m: -------------------------------------------------------------------------------- 1 | #import "info.h" 2 | #import 3 | #import 4 | #import 5 | 6 | ModifierKeys getModifierKeys() { 7 | NSEventModifierFlags flags = [NSEvent modifierFlags]; 8 | ModifierKeys keys = { 9 | .shift = (flags & NSEventModifierFlagShift) != 0, 10 | .option = (flags & NSEventModifierFlagOption) != 0, 11 | .command = (flags & NSEventModifierFlagCommand) != 0, 12 | .control = (flags & NSEventModifierFlagControl) != 0, 13 | .capsLock = (flags & NSEventModifierFlagCapsLock) != 0, 14 | .fn = (flags & NSEventModifierFlagFunction) != 0 15 | }; 16 | return keys; 17 | } 18 | 19 | SystemInfo getSystemInfo() { 20 | NSHost *currentHost = [NSHost currentHost]; 21 | NSString *localizedNameStr = [currentHost localizedName] ?: @""; 22 | NSString *nameStr = [currentHost name] ?: @""; 23 | 24 | SystemInfo info = { 25 | .localizedName = [localizedNameStr UTF8String], 26 | .name = [nameStr UTF8String] 27 | }; 28 | return info; 29 | } 30 | 31 | PowerInfo getPowerInfo() { 32 | PowerInfo info = { 33 | .isConnected = false, 34 | .isCharging = false, 35 | .percentage = -1 36 | }; 37 | 38 | CFTypeRef powerSourcesInfo = IOPSCopyPowerSourcesInfo(); 39 | if (!powerSourcesInfo) { 40 | return info; 41 | } 42 | 43 | CFArrayRef powerSources = IOPSCopyPowerSourcesList(powerSourcesInfo); 44 | if (!powerSources) { 45 | CFRelease(powerSourcesInfo); 46 | return info; 47 | } 48 | 49 | CFIndex count = CFArrayGetCount(powerSources); 50 | if (count == 0) { 51 | CFRelease(powerSources); 52 | CFRelease(powerSourcesInfo); 53 | return info; 54 | } 55 | 56 | // Get the first power source (usually the internal battery) 57 | CFDictionaryRef powerSource = CFArrayGetValueAtIndex(powerSources, 0); 58 | if (!powerSource) { 59 | CFRelease(powerSources); 60 | CFRelease(powerSourcesInfo); 61 | return info; 62 | } 63 | 64 | // Check if it's a battery 65 | CFStringRef powerSourceType = CFDictionaryGetValue(powerSource, CFSTR(kIOPSTypeKey)); 66 | if (!powerSourceType || !CFEqual(powerSourceType, CFSTR(kIOPSInternalBatteryType))) { 67 | CFRelease(powerSources); 68 | CFRelease(powerSourcesInfo); 69 | return info; 70 | } 71 | 72 | // Get battery information 73 | CFBooleanRef isChargingRef = CFDictionaryGetValue(powerSource, CFSTR(kIOPSIsChargingKey)); 74 | if (isChargingRef) { 75 | info.isCharging = CFBooleanGetValue(isChargingRef); 76 | } 77 | 78 | CFStringRef powerSourceStateRef = CFDictionaryGetValue(powerSource, CFSTR(kIOPSPowerSourceStateKey)); 79 | if (powerSourceStateRef) { 80 | info.isConnected = CFEqual(powerSourceStateRef, CFSTR(kIOPSACPowerValue)); 81 | } 82 | 83 | CFNumberRef percentageRef = CFDictionaryGetValue(powerSource, CFSTR(kIOPSCurrentCapacityKey)); 84 | if (percentageRef) { 85 | CFNumberGetValue(percentageRef, kCFNumberIntType, &info.percentage); 86 | } 87 | 88 | CFRelease(powerSources); 89 | CFRelease(powerSourcesInfo); 90 | 91 | return info; 92 | } 93 | 94 | _Bool isAppRunning(const char* identifier) { 95 | if (!identifier) { 96 | return 0; 97 | } 98 | 99 | NSString *identifierStr = [NSString stringWithUTF8String:identifier]; 100 | NSArray *runningApps = [[NSWorkspace sharedWorkspace] runningApplications]; 101 | 102 | for (NSRunningApplication *app in runningApps) { 103 | // Check bundle ID 104 | if ([[app bundleIdentifier] isEqualToString:identifierStr]) { 105 | return 1; 106 | } 107 | 108 | // Check app name 109 | NSString *appName = [app localizedName]; 110 | if ([appName isEqualToString:identifierStr]) { 111 | return 1; 112 | } 113 | } 114 | 115 | return 0; 116 | } 117 | -------------------------------------------------------------------------------- /apps/finicky/src/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/dop251/goja" 16 | ) 17 | 18 | const updateCheckInterval = 24 * time.Hour 19 | 20 | type ReleaseInfo struct { 21 | HasUpdate bool `json:"hasUpdate"` 22 | LatestVersion string `json:"latestVersion"` 23 | DownloadUrl string `json:"downloadUrl"` 24 | ReleaseUrl string `json:"releaseUrl"` 25 | } 26 | 27 | type UpdateCheckInfo struct { 28 | Timestamp int64 `json:"timestamp"` 29 | ReleaseInfo ReleaseInfo `json:"releaseInfo"` 30 | } 31 | 32 | var ( 33 | // These will be set via ldflags during build 34 | commitHash = "dev" 35 | buildDate = "unknown" 36 | apiHost = "" 37 | ) 38 | 39 | // GetBuildInfo returns the commit hash and build date 40 | func GetBuildInfo() (string, string) { 41 | return commitHash, buildDate 42 | } 43 | 44 | func getCacheDir() string { 45 | cacheDir, err := os.UserCacheDir() 46 | if err != nil { 47 | slog.Error("Error getting user cache directory", "error", err) 48 | return "" 49 | } 50 | cacheDir = filepath.Join(cacheDir, "Finicky") 51 | if err := os.MkdirAll(cacheDir, 0755); err != nil { 52 | slog.Error("Error creating cache directory", "error", err) 53 | return "" 54 | } 55 | return cacheDir 56 | } 57 | 58 | func getLastUpdateCheck() *UpdateCheckInfo { 59 | cacheDir := getCacheDir() 60 | if cacheDir == "" { 61 | return nil 62 | } 63 | 64 | cacheFile := filepath.Join(cacheDir, "last_update_check.json") 65 | data, err := os.ReadFile(cacheFile) 66 | if err != nil { 67 | return nil 68 | } 69 | 70 | var info UpdateCheckInfo 71 | if err := json.Unmarshal(data, &info); err != nil { 72 | return nil 73 | } 74 | 75 | return &info 76 | } 77 | 78 | func setLastUpdateCheck(info UpdateCheckInfo) { 79 | cacheDir := getCacheDir() 80 | if cacheDir == "" { 81 | return 82 | } 83 | 84 | data, err := json.Marshal(info) 85 | if err != nil { 86 | slog.Error("Error marshaling update check info", "error", err) 87 | return 88 | } 89 | 90 | cacheFile := filepath.Join(cacheDir, "last_update_check.json") 91 | if err := os.WriteFile(cacheFile, data, 0644); err != nil { 92 | slog.Error("Error saving last update check info", "error", err) 93 | } 94 | } 95 | 96 | func GetCurrentVersion() string { 97 | // Get the bundle path 98 | bundlePath := os.Getenv("BUNDLE_PATH") 99 | if bundlePath == "" { 100 | execPath, err := os.Executable() 101 | if err != nil { 102 | slog.Error("Error getting executable path", "error", err) 103 | return "" 104 | } 105 | 106 | bundlePath = filepath.Join(filepath.Dir(execPath), "..", "Info.plist") 107 | } 108 | 109 | // Read and parse Info.plist 110 | cmd := exec.Command("defaults", "read", bundlePath, "CFBundleVersion") 111 | output, err := cmd.Output() 112 | if err != nil { 113 | slog.Error("Error reading version from Info.plist", "error", err) 114 | return "" 115 | } 116 | 117 | version := strings.TrimSpace(string(output)) 118 | 119 | if version == "" { 120 | slog.Error("Could not determine current version") 121 | return "dev" 122 | } 123 | 124 | return version 125 | } 126 | 127 | func checkForUpdates() (releaseInfo *ReleaseInfo) { 128 | currentVersion := GetCurrentVersion() 129 | if currentVersion == "" { 130 | slog.Info("Could not determine current version") 131 | return nil 132 | } 133 | 134 | slog.Debug("Checking update schedule...") 135 | 136 | updateCheckInfo := getLastUpdateCheck() 137 | if updateCheckInfo != nil && updateCheckInfo.Timestamp > 0 { 138 | timeSinceLastCheck := time.Since(time.Unix(updateCheckInfo.Timestamp, 0)) 139 | if timeSinceLastCheck < updateCheckInterval { 140 | slog.Debug("Skipping update check - last checked", "duration", fmt.Sprintf("%dh %dm ago (check interval: %dh)", int(timeSinceLastCheck.Hours()), int(timeSinceLastCheck.Minutes())%60, int(updateCheckInterval.Hours()))) 141 | return &updateCheckInfo.ReleaseInfo 142 | } 143 | } 144 | 145 | slog.Info("Checking for updates...") 146 | 147 | // Create HTTP client with timeout 148 | client := &http.Client{ 149 | Timeout: 3 * time.Second, 150 | } 151 | 152 | if apiHost == "" { 153 | slog.Warn("apiHost is not set, won't check for updates") 154 | return nil 155 | } 156 | 157 | // Create request 158 | apiUrl := fmt.Sprintf("%s/update-check?version=%s", apiHost, currentVersion) 159 | req, err := http.NewRequest("GET", apiUrl, nil) 160 | if err != nil { 161 | slog.Error("Error creating request", "error", err) 162 | return nil 163 | } 164 | 165 | // Set User-Agent header 166 | req.Header.Set("User-Agent", fmt.Sprintf("finicky/%s", currentVersion)) 167 | 168 | // Make request 169 | resp, err := client.Do(req) 170 | if err != nil { 171 | slog.Error("Error making request", "error", err) 172 | return nil 173 | } 174 | defer resp.Body.Close() 175 | 176 | // Read response 177 | body, err := io.ReadAll(resp.Body) 178 | if err != nil { 179 | slog.Error("Error reading response", "error", err) 180 | return nil 181 | } 182 | 183 | // Parse releases 184 | if err := json.Unmarshal(body, &releaseInfo); err != nil { 185 | slog.Error("Error parsing release info", "error", err, "response", string(body)) 186 | return nil 187 | } 188 | 189 | // Update the last check time 190 | setLastUpdateCheck(UpdateCheckInfo{ 191 | Timestamp: time.Now().Unix(), 192 | ReleaseInfo: *releaseInfo, 193 | }) 194 | 195 | return releaseInfo 196 | } 197 | 198 | // CheckForUpdatesIfEnabled checks if updates should be performed based on VM configuration 199 | func CheckForUpdatesIfEnabled(vm *goja.Runtime) (releaseInfo *ReleaseInfo, updateCheckEnabled bool, err error) { 200 | 201 | if vm == nil { 202 | // Check for updates if we don't have a VM 203 | releaseInfo := checkForUpdates() 204 | return releaseInfo, true, nil 205 | } 206 | 207 | // Check checkForUpdates option 208 | shouldCheckForUpdates, err := vm.RunString("finickyConfigAPI.getOption('checkForUpdates', finalConfig, true)") 209 | if err != nil { 210 | return nil, true, fmt.Errorf("failed to get checkForUpdates option: %v", err) 211 | } 212 | 213 | if shouldCheckForUpdates.ToBoolean() { 214 | releaseInfo := checkForUpdates() 215 | return releaseInfo, true, nil 216 | } else { 217 | slog.Debug("Skipping update check") 218 | } 219 | return nil, false, nil 220 | } 221 | -------------------------------------------------------------------------------- /apps/finicky/src/window/window.go: -------------------------------------------------------------------------------- 1 | package window 2 | 3 | /* 4 | #cgo CFLAGS: -x objective-c 5 | #cgo LDFLAGS: -framework Cocoa -framework WebKit 6 | #include 7 | #include "window.h" 8 | */ 9 | import "C" 10 | import ( 11 | "encoding/json" 12 | "finicky/assets" 13 | "finicky/version" 14 | "fmt" 15 | "io/fs" 16 | "log/slog" 17 | "net/http" 18 | "path/filepath" 19 | "strings" 20 | "sync" 21 | "unsafe" 22 | ) 23 | 24 | var ( 25 | messageQueue []string 26 | queueMutex sync.Mutex 27 | windowReady bool 28 | ) 29 | 30 | //export WindowIsReady 31 | func WindowIsReady() { 32 | queueMutex.Lock() 33 | windowReady = true 34 | // Process any queued messages 35 | for _, message := range messageQueue { 36 | sendMessageToWebViewInternal(message) 37 | } 38 | messageQueue = nil 39 | queueMutex.Unlock() 40 | } 41 | 42 | func sendMessageToWebViewInternal(message string) { 43 | cMessage := C.CString(message) 44 | defer C.free(unsafe.Pointer(cMessage)) 45 | C.SendMessageToWebView(cMessage) 46 | } 47 | 48 | func SendMessageToWebView(messageType string, message interface{}) { 49 | jsonMsg := struct { 50 | Type string `json:"type"` 51 | Message interface{} `json:"message"` 52 | }{ 53 | Type: messageType, 54 | Message: message, 55 | } 56 | jsonBytes, err := json.Marshal(jsonMsg) 57 | if err != nil { 58 | slog.Error("Error marshaling message", "error", err) 59 | return 60 | } 61 | 62 | queueMutex.Lock() 63 | defer queueMutex.Unlock() 64 | 65 | if windowReady { 66 | sendMessageToWebViewInternal(string(jsonBytes)) 67 | } else { 68 | messageQueue = append(messageQueue, string(jsonBytes)) 69 | } 70 | } 71 | 72 | func init() { 73 | // Load HTML content 74 | html, err := assets.GetHTML() 75 | if err != nil { 76 | slog.Error("Error loading HTML content", "error", err) 77 | return 78 | } 79 | 80 | // Set HTML content 81 | cContent := C.CString(html) 82 | defer C.free(unsafe.Pointer(cContent)) 83 | C.SetHTMLContent(cContent) 84 | 85 | // Get the filesystem and walk through all files in templates directory 86 | filesystem := assets.GetFileSystem() 87 | err = fs.WalkDir(filesystem, "templates", func(path string, d fs.DirEntry, err error) error { 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // Skip directories and index.html (already handled by GetHTML) 93 | if d.IsDir() || filepath.Base(path) == "index.html" { 94 | return nil 95 | } 96 | 97 | // Get the file content 98 | content, err := assets.GetFile(filepath.Base(path)) 99 | if err != nil { 100 | slog.Error("Error loading file", "path", path, "error", err) 101 | return nil 102 | } 103 | 104 | cPath := C.CString(filepath.Base(path)) 105 | cContent := C.CString(string(content)) 106 | defer C.free(unsafe.Pointer(cPath)) 107 | defer C.free(unsafe.Pointer(cContent)) 108 | 109 | // Detect content type 110 | contentType := http.DetectContentType(content) 111 | if strings.HasPrefix(contentType, "text/") || strings.HasPrefix(contentType, "application/javascript") { 112 | C.SetFileContent(cPath, cContent) 113 | } else { 114 | // Handle binary files 115 | C.SetFileContentWithLength(cPath, cContent, C.size_t(len(content))) 116 | } 117 | return nil 118 | }) 119 | 120 | if err != nil { 121 | slog.Error("Error walking templates directory", "error", err) 122 | } 123 | } 124 | 125 | func ShowWindow() { 126 | C.ShowWindow() 127 | SendBuildInfo() 128 | } 129 | 130 | func CloseWindow() { 131 | C.CloseWindow() 132 | } 133 | 134 | func SendBuildInfo() { 135 | commitHash, buildDate := version.GetBuildInfo() 136 | buildInfo := fmt.Sprintf("(%s, built %s)", commitHash, buildDate) 137 | SendMessageToWebView("buildInfo", buildInfo) 138 | } 139 | -------------------------------------------------------------------------------- /apps/finicky/src/window/window.h: -------------------------------------------------------------------------------- 1 | #ifndef WINDOW_H 2 | #define WINDOW_H 3 | 4 | #import 5 | #import 6 | 7 | @interface WindowController : NSObject 8 | - (void)showWindow; 9 | - (void)closeWindow; 10 | - (void)sendMessageToWebView:(NSString *)message; 11 | @end 12 | 13 | void ShowWindow(void); 14 | void CloseWindow(void); 15 | void SendMessageToWebView(const char* message); 16 | void SetHTMLContent(const char* content); 17 | void SetFileContent(const char* path, const char* content); 18 | void SetFileContentWithLength(const char* path, const char* content, size_t length); 19 | 20 | extern void WindowDidClose(void); 21 | extern void WindowIsReady(void); 22 | 23 | #endif /* WINDOW_H */ -------------------------------------------------------------------------------- /apps/finicky/src/window/window.m: -------------------------------------------------------------------------------- 1 | #import "window.h" 2 | 3 | static WindowController* windowController = nil; 4 | static NSString* htmlContent = nil; 5 | static NSMutableDictionary* fileContents = nil; 6 | 7 | void SetHTMLContent(const char* content) { 8 | if (content) { 9 | htmlContent = [NSString stringWithUTF8String:content]; 10 | } 11 | } 12 | 13 | void SetFileContent(const char* path, const char* content) { 14 | if (!fileContents) { 15 | fileContents = [[NSMutableDictionary alloc] init]; 16 | } 17 | if (path && content) { 18 | NSString* pathStr = [NSString stringWithUTF8String:path]; 19 | if ([pathStr hasSuffix:@".png"]) { 20 | // For PNG files, use SetFileContentWithLength instead 21 | SetFileContentWithLength(path, content, strlen(content)); 22 | } else { 23 | // For text files, store as string 24 | NSString* contentStr = [NSString stringWithUTF8String:content]; 25 | fileContents[pathStr] = contentStr; 26 | } 27 | } 28 | } 29 | 30 | void SetFileContentWithLength(const char* path, const char* content, size_t length) { 31 | if (!fileContents) { 32 | fileContents = [[NSMutableDictionary alloc] init]; 33 | } 34 | if (path && content) { 35 | NSString* pathStr = [NSString stringWithUTF8String:path]; 36 | NSData* data = [[NSData alloc] initWithBytes:content length:length]; 37 | fileContents[pathStr] = data; 38 | } 39 | } 40 | 41 | @implementation WindowController { 42 | NSWindow* window; 43 | WKWebView* webView; 44 | } 45 | 46 | - (id)init { 47 | self = [super init]; 48 | NSLog(@"Initialize window controller"); 49 | if (self) { 50 | // Always setup window on main thread 51 | if ([NSThread isMainThread]) { 52 | [self setupWindow]; 53 | [self setupMenu]; 54 | } else { 55 | dispatch_sync(dispatch_get_main_queue(), ^{ 56 | [self setupWindow]; 57 | [self setupMenu]; 58 | }); 59 | } 60 | } 61 | return self; 62 | } 63 | 64 | - (void)setupWindow { 65 | // Create window 66 | window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600) 67 | styleMask:NSWindowStyleMaskTitled | 68 | NSWindowStyleMaskClosable | 69 | NSWindowStyleMaskMiniaturizable | 70 | NSWindowStyleMaskResizable 71 | backing:NSBackingStoreBuffered 72 | defer:NO]; 73 | [window setTitle:@"Finicky"]; 74 | [window center]; 75 | [window setReleasedWhenClosed:NO]; 76 | [window setBackgroundColor:[NSColor colorWithCalibratedWhite:0.1 alpha:1.0]]; 77 | 78 | // Configure WKWebView 79 | WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; 80 | [config.userContentController addScriptMessageHandler:self name:@"finicky"]; 81 | [config setURLSchemeHandler:self forURLScheme:@"finicky-assets"]; 82 | 83 | // Create WKWebView 84 | webView = [[WKWebView alloc] initWithFrame:window.contentView.bounds configuration:config]; 85 | webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; 86 | webView.navigationDelegate = self; 87 | webView.wantsLayer = YES; 88 | webView.layer.backgroundColor = [NSColor colorWithCalibratedWhite:0.1 alpha:1.0].CGColor; 89 | 90 | [webView.configuration.preferences setValue:@YES forKey:@"developerExtrasEnabled"]; 91 | 92 | // Load HTML content 93 | if (htmlContent) { 94 | NSURL* baseURL = [NSURL URLWithString:@"finicky-assets://local/"]; 95 | [webView loadHTMLString:htmlContent baseURL:baseURL]; 96 | } else { 97 | NSLog(@"Warning: HTML content not set"); 98 | } 99 | 100 | // Add window close notification observer 101 | [[NSNotificationCenter defaultCenter] addObserver:self 102 | selector:@selector(windowWillClose:) 103 | name:NSWindowWillCloseNotification 104 | object:window]; 105 | 106 | // Set webView as content view 107 | window.contentView = webView; 108 | } 109 | 110 | - (void)showWindow { 111 | if ([NSThread isMainThread]) { 112 | [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; 113 | [window makeKeyAndOrderFront:nil]; 114 | [NSApp activateIgnoringOtherApps:YES]; 115 | } else { 116 | dispatch_async(dispatch_get_main_queue(), ^{ 117 | [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; 118 | [window makeKeyAndOrderFront:nil]; 119 | [NSApp activateIgnoringOtherApps:YES]; 120 | }); 121 | } 122 | } 123 | 124 | - (void)closeWindow { 125 | if ([NSThread isMainThread]) { 126 | [window close]; 127 | } else { 128 | dispatch_async(dispatch_get_main_queue(), ^{ 129 | [window close]; 130 | }); 131 | } 132 | } 133 | 134 | - (void)sendMessageToWebView:(NSString *)message { 135 | // The message is already JSON encoded from Go, pass it as a string literal, but escape the quotes and backslashes 136 | NSString *escapedMessage = [[message stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"] 137 | stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""]; 138 | NSString *js = [NSString stringWithFormat:@"finicky.receiveMessage(\"%@\")", escapedMessage]; 139 | 140 | if ([NSThread isMainThread]) { 141 | if (webView && !webView.loading) { 142 | [webView evaluateJavaScript:js completionHandler:nil]; 143 | } 144 | } else { 145 | dispatch_async(dispatch_get_main_queue(), ^{ 146 | if (webView && !webView.loading) { 147 | [webView evaluateJavaScript:js completionHandler:nil]; 148 | } 149 | }); 150 | } 151 | } 152 | 153 | - (void)userContentController:(WKUserContentController *)userContentController 154 | didReceiveScriptMessage:(WKScriptMessage *)message { 155 | // Handle messages from JavaScript here 156 | NSLog(@"Received message from WebView: %@", message.body); 157 | } 158 | 159 | #pragma mark - WKURLSchemeHandler 160 | 161 | - (void)webView:(WKWebView *)webView startURLSchemeTask:(id)urlSchemeTask { 162 | NSURLRequest *request = urlSchemeTask.request; 163 | NSString *path = request.URL.path; 164 | if ([path hasPrefix:@"/"]) { 165 | path = [path substringFromIndex:1]; 166 | } 167 | 168 | // Remove 'local/' prefix if present 169 | if ([path hasPrefix:@"local/"]) { 170 | path = [path substringFromIndex:6]; 171 | } 172 | 173 | id content = fileContents[path]; 174 | if (content) { 175 | NSData *data; 176 | if ([content isKindOfClass:[NSData class]]) { 177 | data = (NSData *)content; 178 | } else { 179 | data = [(NSString *)content dataUsingEncoding:NSUTF8StringEncoding]; 180 | } 181 | 182 | NSString *mimeType = @"text/plain"; 183 | if ([path hasSuffix:@".css"]) { 184 | mimeType = @"text/css"; 185 | } else if ([path hasSuffix:@".js"]) { 186 | mimeType = @"application/javascript"; 187 | } else if ([path hasSuffix:@".png"]) { 188 | mimeType = @"image/png"; 189 | } 190 | 191 | NSURLResponse *response = [[NSURLResponse alloc] initWithURL:request.URL 192 | MIMEType:mimeType 193 | expectedContentLength:data.length 194 | textEncodingName:nil]; 195 | 196 | [urlSchemeTask didReceiveResponse:response]; 197 | [urlSchemeTask didReceiveData:data]; 198 | [urlSchemeTask didFinish]; 199 | } else { 200 | NSLog(@"Asset not found: %@", path); 201 | [urlSchemeTask didFailWithError:[NSError errorWithDomain:NSURLErrorDomain 202 | code:NSURLErrorResourceUnavailable 203 | userInfo:nil]]; 204 | } 205 | } 206 | 207 | - (void)webView:(WKWebView *)webView stopURLSchemeTask:(id)urlSchemeTask { 208 | // Nothing to do here 209 | } 210 | 211 | #pragma mark - WKNavigationDelegate 212 | 213 | - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { 214 | // Notify Go that the window is ready to receive messages 215 | extern void WindowIsReady(void); 216 | WindowIsReady(); 217 | } 218 | 219 | - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { 220 | NSURL *url = navigationAction.request.URL; 221 | 222 | // Handle finicky-assets:// URLs internally 223 | if ([url.scheme isEqualToString:@"finicky-assets"]) { 224 | decisionHandler(WKNavigationActionPolicyAllow); 225 | return; 226 | } 227 | 228 | // If it's a regular link click (not a page load) 229 | if (navigationAction.navigationType == WKNavigationTypeLinkActivated) { 230 | // Open the URL in the default browser 231 | [[NSWorkspace sharedWorkspace] openURL:url]; 232 | decisionHandler(WKNavigationActionPolicyCancel); 233 | return; 234 | } 235 | 236 | // Allow all other navigation 237 | decisionHandler(WKNavigationActionPolicyAllow); 238 | } 239 | 240 | // Add new method to handle window close 241 | - (void)windowWillClose:(NSNotification *)notification { 242 | extern void WindowDidClose(void); 243 | WindowDidClose(); 244 | } 245 | 246 | - (void)setupMenu { 247 | NSMenu *mainMenu = [[NSMenu alloc] init]; 248 | [NSApp setMainMenu:mainMenu]; 249 | 250 | // Application menu 251 | NSMenuItem *appMenuItem = [[NSMenuItem alloc] init]; 252 | [mainMenu addItem:appMenuItem]; 253 | NSMenu *appMenu = [[NSMenu alloc] init]; 254 | [appMenuItem setSubmenu:appMenu]; 255 | 256 | // Quit menu item (⌘Q) 257 | NSMenuItem *quitMenuItem = [[NSMenuItem alloc] initWithTitle:@"Quit" 258 | action:@selector(terminate:) 259 | keyEquivalent:@"q"]; 260 | [quitMenuItem setTarget:NSApp]; 261 | [appMenu addItem:quitMenuItem]; 262 | 263 | // File menu 264 | NSMenuItem *fileMenuItem = [[NSMenuItem alloc] init]; 265 | [mainMenu addItem:fileMenuItem]; 266 | NSMenu *fileMenu = [[NSMenu alloc] initWithTitle:@"File"]; 267 | [fileMenuItem setSubmenu:fileMenu]; 268 | 269 | // Close window menu item (⌘W) 270 | NSMenuItem *closeMenuItem = [[NSMenuItem alloc] initWithTitle:@"Close Window" 271 | action:@selector(performClose:) 272 | keyEquivalent:@"w"]; 273 | [closeMenuItem setTarget:window]; 274 | [fileMenu addItem:closeMenuItem]; 275 | 276 | // Edit menu 277 | NSMenuItem *editMenuItem = [[NSMenuItem alloc] init]; 278 | [mainMenu addItem:editMenuItem]; 279 | NSMenu *editMenu = [[NSMenu alloc] initWithTitle:@"Edit"]; 280 | [editMenuItem setSubmenu:editMenu]; 281 | 282 | // Add Copy menu item (⌘C) 283 | NSMenuItem *copyMenuItem = [[NSMenuItem alloc] initWithTitle:@"Copy" 284 | action:@selector(copy:) 285 | keyEquivalent:@"c"]; 286 | [editMenu addItem:copyMenuItem]; 287 | } 288 | 289 | @end 290 | 291 | void ShowWindow(void) { 292 | if (!windowController) { 293 | windowController = [[WindowController alloc] init]; 294 | } 295 | [windowController showWindow]; 296 | } 297 | 298 | void CloseWindow(void) { 299 | if (windowController) { 300 | [windowController closeWindow]; 301 | } 302 | } 303 | 304 | void SendMessageToWebView(const char* message) { 305 | if (windowController) { 306 | NSString *nsMessage = [NSString stringWithUTF8String:message]; 307 | [windowController sendMessageToWebView:nsMessage]; 308 | } 309 | } -------------------------------------------------------------------------------- /example-config/example.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @typedef {import('/Applications/Finicky.app/Contents/Resources/finicky.d.ts')} Globals 5 | * @typedef {import('/Applications/Finicky.app/Contents/Resources/finicky.d.ts').FinickyConfig} FinickyConfig 6 | */ 7 | 8 | const { matchHostnames } = finicky; 9 | 10 | /** 11 | * @type {FinickyConfig} 12 | */ 13 | export default { 14 | defaultBrowser: { name: "Firefox" }, 15 | options: { 16 | urlShorteners: [], 17 | logRequests: true, 18 | }, 19 | rewrite: [ 20 | { 21 | match: "*query=value*", 22 | url: (url) => url, 23 | }, 24 | { 25 | match: () => { 26 | console.log(finicky.getModifierKeys()); 27 | console.log(finicky.getSystemInfo()); 28 | return false; 29 | }, 30 | url: (url) => url, 31 | }, 32 | ], 33 | 34 | handlers: [ 35 | { 36 | // Open workplace related sites in work browser 37 | match: "?query=value", 38 | browser: "Safari", // "Arc" // "Brave Browser", //, //"Firefox Developer Edition" 39 | }, 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /example-config/example.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BrowserHandler, 3 | FinickyConfig, 4 | } from "/Applications/Finicky.app/Contents/Resources/finicky.d.ts"; 5 | 6 | const handler: BrowserHandler = { 7 | match: (url: URL, { opener }) => { 8 | return url.host.includes("workplace"); 9 | }, 10 | browser: "Firefox", 11 | }; 12 | 13 | export default { 14 | defaultBrowser: "Google Chrome", 15 | options: { 16 | checkForUpdates: false, 17 | }, 18 | handlers: [ 19 | handler, 20 | { 21 | match: (url: URL, { opener }) => { 22 | console.log("opener", opener); 23 | return opener?.name.includes("Slack") || false; 24 | }, 25 | browser: "Firefox", 26 | }, 27 | ], 28 | } satisfies FinickyConfig; 29 | -------------------------------------------------------------------------------- /packages/config-api/README.md: -------------------------------------------------------------------------------- 1 | # Config API 2 | 3 | This package provides the functionality to 1) validate and 2) evaluate urls and determining the resulting browser to open 4 | -------------------------------------------------------------------------------- /packages/config-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "check-types": "tsc --noEmit", 4 | "build": "esbuild --global-name=finickyConfigAPI --bundle src/index.ts --outfile=dist/finickyConfigAPI.js --target=es2015 --format=iife --log-level=error --external:typescript --external:@types/* --tree-shaking=true", 5 | "generate-types": "node --experimental-strip-types ./scripts/generate-typedefs.ts", 6 | "test": "vitest" 7 | }, 8 | "type": "module", 9 | "dependencies": { 10 | "core-js": "^3.40.0", 11 | "zod": "^3.24.1", 12 | "zod-validation-error": "^3.4.0" 13 | }, 14 | "devDependencies": { 15 | "@duplojs/zod-to-typescript": "^0.4.0", 16 | "@types/node": "^22.10.7", 17 | "esbuild": "^0.25.0", 18 | "typescript": "^5.7.2", 19 | "vitest": "^3.0.7" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/config-api/scripts/generate-typedefs.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { ZodToTypescript } from "@duplojs/zod-to-typescript"; 3 | import { ConfigSchema } from "../src/configSchema.ts"; 4 | 5 | const tsType = ZodToTypescript.convert(ConfigSchema, { 6 | name: "FinickyConfig", 7 | export: true, 8 | }); 9 | 10 | // TODO: Generate the FinickyUtils interface automatically 11 | const output = ` 12 | /** This file is generated by the generate-typedefs.ts script. Do not edit it directly. */ 13 | 14 | interface FinickyUtils { 15 | matchHostnames: (hostnames: Array | string | RegExp) => (url: URL) => boolean; 16 | getModifierKeys: () => { 17 | shift: boolean; 18 | option: boolean; 19 | command: boolean; 20 | control: boolean; 21 | capsLock: boolean; 22 | fn: boolean; 23 | }; 24 | getSystemInfo: () => { 25 | localizedName: string; 26 | name: string; 27 | }; 28 | getPowerInfo: () => { 29 | isCharging: boolean; 30 | isConnected: boolean; 31 | percentage: number | null; 32 | }; 33 | isAppRunning: (identifier: string) => boolean; 34 | } 35 | 36 | declare global { 37 | const finicky: FinickyUtils 38 | } 39 | 40 | export ${tsType} 41 | `; 42 | 43 | fs.writeFileSync("./dist/finicky.d.ts", output, "utf-8"); 44 | -------------------------------------------------------------------------------- /packages/config-api/src/FinickyURL.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import { FinickyURL } from "./FinickyURL"; 3 | import { ProcessInfo } from "./configSchema"; 4 | import * as legacyURLObjectModule from "./legacyURLObject"; 5 | 6 | describe("FinickyURL", () => { 7 | // Setup console.warn spy 8 | let consoleWarnSpy: any; 9 | 10 | beforeEach(() => { 11 | // Mock console.warn to track deprecation warnings 12 | consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 13 | }); 14 | 15 | afterEach(() => { 16 | // Restore console.warn after each test 17 | consoleWarnSpy.mockRestore(); 18 | }); 19 | 20 | describe("constructor", () => { 21 | it("should create a FinickyURL instance with a valid URL", () => { 22 | const url = new FinickyURL("https://example.com"); 23 | expect(url).toBeInstanceOf(FinickyURL); 24 | expect(url).toBeInstanceOf(URL); 25 | expect(url.href).toBe("https://example.com/"); 26 | }); 27 | 28 | it("should create a FinickyURL instance with opener information", () => { 29 | const opener: ProcessInfo = { 30 | name: "TestApp", 31 | bundleId: "com.test.app", 32 | path: "/Applications/TestApp.app", 33 | }; 34 | const url = new FinickyURL("https://example.com", opener); 35 | expect(url).toBeInstanceOf(FinickyURL); 36 | expect(url.href).toBe("https://example.com/"); 37 | }); 38 | 39 | it("should throw an error for invalid URLs", () => { 40 | expect(() => new FinickyURL("invalid-url")).toThrow(); 41 | }); 42 | }); 43 | 44 | describe("urlString property", () => { 45 | it("should return the href and show deprecation warning", () => { 46 | const url = new FinickyURL( 47 | "https://example.com/path?query=value#fragment" 48 | ); 49 | 50 | expect(url.urlString).toBe( 51 | "https://example.com/path?query=value#fragment" 52 | ); 53 | expect(consoleWarnSpy).toHaveBeenCalledWith( 54 | expect.stringContaining('Accessing legacy property "urlString"') 55 | ); 56 | }); 57 | }); 58 | 59 | describe("url property", () => { 60 | it("should return a LegacyURLObject and show deprecation warning", () => { 61 | const url = new FinickyURL( 62 | "https://user:pass@example.com:8080/path?query=value#fragment" 63 | ); 64 | const spy = vi.spyOn(legacyURLObjectModule, "URLtoLegacyURLObject"); 65 | 66 | const legacyUrl = url.url; 67 | 68 | expect(consoleWarnSpy).toHaveBeenCalledWith( 69 | expect.stringContaining('Accessing legacy property "url"') 70 | ); 71 | expect(spy).toHaveBeenCalledWith(url); 72 | 73 | // Verify the legacy URL object properties 74 | expect(legacyUrl.host).toBe("example.com"); 75 | expect(legacyUrl.protocol).toBe("https"); 76 | expect(legacyUrl.pathname).toBe("/path"); 77 | expect(legacyUrl.search).toBe("query=value"); 78 | expect(legacyUrl.username).toBe("user"); 79 | expect(legacyUrl.password).toBe("pass"); 80 | expect(legacyUrl.port).toBe(8080); 81 | expect(legacyUrl.hash).toBe("fragment"); 82 | }); 83 | }); 84 | 85 | describe("opener property", () => { 86 | it("should return the opener and show deprecation warning", () => { 87 | const opener: ProcessInfo = { 88 | name: "TestApp", 89 | bundleId: "com.test.app", 90 | path: "/Applications/TestApp.app", 91 | }; 92 | const url = new FinickyURL("https://example.com", opener); 93 | 94 | expect(url.opener).toEqual(opener); 95 | expect(consoleWarnSpy).toHaveBeenCalledWith( 96 | expect.stringContaining('Accessing legacy property "opener"') 97 | ); 98 | }); 99 | 100 | it("should return null when no opener is provided", () => { 101 | const url = new FinickyURL("https://example.com"); 102 | 103 | expect(url.opener).toBeNull(); 104 | expect(consoleWarnSpy).toHaveBeenCalledWith( 105 | expect.stringContaining('Accessing legacy property "opener"') 106 | ); 107 | }); 108 | }); 109 | 110 | describe("keys property", () => { 111 | it("should throw an error with a message about using finicky.getModifierKeys()", () => { 112 | const url = new FinickyURL("https://example.com"); 113 | 114 | expect(() => url.keys).toThrow( 115 | 'Accessing legacy property "keys" that is no longer supported, please use finicky.getModifierKeys() instead.' 116 | ); 117 | }); 118 | }); 119 | 120 | describe("URL inheritance", () => { 121 | it("should inherit all URL properties", () => { 122 | const url = new FinickyURL( 123 | "https://user:pass@example.com:8080/path?query=value#fragment" 124 | ); 125 | 126 | expect(url.href).toBe( 127 | "https://user:pass@example.com:8080/path?query=value#fragment" 128 | ); 129 | expect(url.protocol).toBe("https:"); 130 | expect(url.hostname).toBe("example.com"); 131 | expect(url.host).toBe("example.com:8080"); 132 | expect(url.pathname).toBe("/path"); 133 | expect(url.search).toBe("?query=value"); 134 | expect(url.hash).toBe("#fragment"); 135 | expect(url.username).toBe("user"); 136 | expect(url.password).toBe("pass"); 137 | expect(url.port).toBe("8080"); 138 | expect(url.origin).toBe("https://example.com:8080"); 139 | }); 140 | 141 | it("should inherit URL methods", () => { 142 | const url = new FinickyURL("https://example.com/path"); 143 | 144 | url.searchParams.append("query", "value"); 145 | expect(url.href).toBe("https://example.com/path?query=value"); 146 | 147 | url.pathname = "/newpath"; 148 | expect(url.href).toBe("https://example.com/newpath?query=value"); 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /packages/config-api/src/FinickyURL.ts: -------------------------------------------------------------------------------- 1 | import { ProcessInfo } from "./configSchema"; 2 | import { LegacyURLObject } from "./legacyURLObject"; 3 | import { URLtoLegacyURLObject } from "./legacyURLObject"; 4 | 5 | /** 6 | * FinickyURL class that extends URL to maintain backward compatibility 7 | * with legacy properties while providing deprecation warnings. 8 | */ 9 | export class FinickyURL extends URL { 10 | private _opener: ProcessInfo | null; 11 | 12 | constructor(url: string, opener: ProcessInfo | null = null) { 13 | super(url); 14 | this._opener = opener; 15 | } 16 | 17 | get urlString(): string { 18 | console.warn( 19 | 'Accessing legacy property "urlString" that is no longer supported. This first argument to the function is a URL instance, you should be able to use its href property directly instead. See https://developer.mozilla.org/en-US/docs/Web/API/URL for reference.' 20 | ); 21 | return this.href; 22 | } 23 | 24 | get url(): LegacyURLObject { 25 | console.warn( 26 | 'Accessing legacy property "url" that is no longer supported. This first argument to the function is a URL instance, a standard interface for URLs. https://developer.mozilla.org/en-US/docs/Web/API/URL' 27 | ); 28 | return URLtoLegacyURLObject(this); 29 | } 30 | 31 | get opener(): ProcessInfo | null { 32 | console.warn( 33 | 'Accessing legacy property "opener" that is no longer supported.' 34 | ); 35 | return this._opener; 36 | } 37 | 38 | get keys() { 39 | throw new Error( 40 | 'Accessing legacy property "keys" that is no longer supported, please use finicky.getModifierKeys() instead.' 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/config-api/src/config.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { openUrl } from "./index"; 3 | import { Config, ProcessInfo } from "./configSchema"; 4 | 5 | describe("openUrl", () => { 6 | const mockProcessInfo: ProcessInfo = { 7 | name: "TestApp", 8 | bundleId: "com.test.app", 9 | path: "/Applications/TestApp.app", 10 | }; 11 | 12 | it("validation", () => { 13 | expect(() => 14 | openUrl("https://example.com", mockProcessInfo, { 15 | defaultBrowser: 123, 16 | }) 17 | ).toThrow("Invalid config"); 18 | }); 19 | 20 | describe("handlers", () => { 21 | const handlerConfig = { 22 | defaultBrowser: "Firefox", 23 | handlers: [ 24 | { match: "browser.spotify.com*", browser: "Spotify" }, 25 | { match: ["github.com*", /amazon/], browser: "Google Chrome" }, 26 | { 27 | match: ["figma.com*", "sketch.com*"], 28 | browser: { name: "Google Chrome", profile: "Design" }, 29 | }, 30 | { 31 | match: (url: URL) => url.pathname.includes("docs"), 32 | browser: "Google Chrome", 33 | }, 34 | ], 35 | }; 36 | 37 | const cases = [ 38 | { url: "https://example.com", expected: "Firefox" }, 39 | { url: "https://browser.spotify.com/track/123", expected: "Spotify" }, 40 | { url: "https://github.com/some-repo", expected: "Google Chrome" }, 41 | { url: "https://amazon.com/product", expected: "Google Chrome" }, 42 | { 43 | url: "https://figma.com/file/123", 44 | expected: { name: "Google Chrome", profile: "Design" }, 45 | }, 46 | { 47 | url: "https://sketch.com/dashboard", 48 | expected: { name: "Google Chrome", profile: "Design" }, 49 | }, 50 | { url: "https://example.com/docs", expected: "Google Chrome" }, 51 | ]; 52 | 53 | cases.forEach(({ url, expected }) => { 54 | it(`handles ${url}`, () => { 55 | const result = openUrl(url, mockProcessInfo, handlerConfig); 56 | expect(result.browser).toMatchObject( 57 | typeof expected === "string" ? { name: expected } : expected 58 | ); 59 | }); 60 | }); 61 | 62 | it("works with null opener", () => { 63 | const result = openUrl("https://example.com", null, handlerConfig); 64 | expect(result.browser).toMatchObject({ name: "Firefox" }); 65 | }); 66 | }); 67 | 68 | describe("rewrites", () => { 69 | const rewriteConfig = { 70 | defaultBrowser: "Safari", 71 | rewrite: [ 72 | { 73 | match: "https://slack-redir.net/link?url=*", 74 | url: (url: URL) => url.href, 75 | }, 76 | { 77 | match: /^https:\/\/t\.co\/.+/, 78 | url: (url: URL) => url.href.replace("t.co", "twitter.com"), 79 | }, 80 | { 81 | match: (url: URL) => url.href.includes("shortened"), 82 | url: (url: URL) => url.href + "/expanded", 83 | }, 84 | { 85 | match: (url: URL) => url.href.includes("use-url-object"), 86 | url: (url: URL) => new URL("https://example.com"), 87 | }, 88 | ], 89 | }; 90 | 91 | const cases = [ 92 | { 93 | input: "https://slack-redir.net/link?url=https://example.com", 94 | expectedUrl: "https://slack-redir.net/link?url=https://example.com", 95 | }, 96 | { 97 | input: "https://t.co/abc123", 98 | expectedUrl: "https://twitter.com/abc123", 99 | }, 100 | { 101 | input: "https://my.shortened.url/123", 102 | expectedUrl: "https://my.shortened.url/123/expanded", 103 | }, 104 | { 105 | input: "https://www.use-url-object.com", 106 | expectedUrl: "https://example.com/", 107 | }, 108 | ]; 109 | 110 | cases.forEach(({ input, expectedUrl }) => { 111 | it(`rewrites ${input}`, () => { 112 | const result = openUrl(input, mockProcessInfo, rewriteConfig); 113 | expect(result.browser).toMatchObject({ 114 | name: "Safari", 115 | url: expectedUrl, 116 | }); 117 | }); 118 | }); 119 | }); 120 | 121 | describe("wildcards", () => { 122 | const wildcardConfig = { 123 | defaultBrowser: "Safari", 124 | handlers: [ 125 | { match: "*.example.com*", browser: "Chrome" }, 126 | { match: ["*.google.*", "mail.*.com*"], browser: "Chrome" }, 127 | ], 128 | }; 129 | 130 | const cases = [ 131 | { 132 | urls: ["https://sub1.example.com", "https://sub2.example.com/path"], 133 | expected: "Chrome", 134 | }, 135 | { 136 | urls: [ 137 | "https://mail.google.com", 138 | "https://docs.google.co.uk", 139 | "https://mail.yahoo.com", 140 | ], 141 | expected: "Chrome", 142 | }, 143 | { 144 | urls: ["https://example.com", "https://google.com"], 145 | expected: "Safari", 146 | }, 147 | ]; 148 | 149 | cases.forEach(({ urls, expected }) => { 150 | urls.forEach((url) => { 151 | it(`${expected} handles ${url}`, () => { 152 | const result = openUrl(url, mockProcessInfo, wildcardConfig); 153 | expect(result.browser).toMatchObject({ name: expected }); 154 | }); 155 | }); 156 | }); 157 | }); 158 | 159 | describe("edge cases", () => { 160 | const edgeCaseConfig = { 161 | defaultBrowser: "Safari", 162 | handlers: [ 163 | { match: "", browser: "Chrome" }, 164 | { match: "https://*", browser: "Firefox" }, 165 | { match: "*?param=value*", browser: "Edge" }, 166 | ], 167 | }; 168 | 169 | const cases = [ 170 | { url: "http://example.com", expected: "Safari" }, 171 | { url: "https://example.com", expected: "Firefox" }, 172 | { url: "http://example.com/?param=value&other=123", expected: "Edge" }, 173 | ]; 174 | 175 | cases.forEach(({ url, expected }) => { 176 | it(`${expected} handles ${url}`, () => { 177 | const result = openUrl(url, mockProcessInfo, edgeCaseConfig); 178 | expect(result.browser).toMatchObject({ name: expected }); 179 | }); 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /packages/config-api/src/configSchema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodType } from "zod"; 2 | 3 | /** 4 | * Define a default implementation of the identifier and overrideTypeNode methods for ZodType if it doesn't exist 5 | * It is defined by @duplojs/zod-to-typescript and used to generate the type definitions, but 6 | * it is not available in the zod library itself. 7 | */ 8 | ZodType.prototype.identifier ??= function () { 9 | return this; 10 | }; 11 | 12 | ZodType.prototype.overrideTypeNode ??= function () { 13 | return this; 14 | }; 15 | 16 | const NativeUrlSchema = z 17 | .instanceof(URL) 18 | .overrideTypeNode((ts) => 19 | ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("URL")) 20 | ); 21 | 22 | const RegexpSchema = z 23 | .instanceof(RegExp) 24 | .overrideTypeNode((ts) => 25 | ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("RegExp")) 26 | ); 27 | 28 | // ===== Process & URL Options ===== 29 | const ProcessInfoSchema = z 30 | .object({ 31 | name: z.string(), 32 | bundleId: z.string(), 33 | path: z.string(), 34 | }) 35 | .identifier("ProcessInfo"); 36 | 37 | export type ProcessInfo = z.infer; 38 | 39 | const OpenUrlOptionsSchema = z 40 | .object({ 41 | opener: ProcessInfoSchema.nullable(), 42 | }) 43 | .identifier("OpenUrlOptions"); 44 | 45 | export type OpenUrlOptions = z.infer; 46 | 47 | // ===== URL Schemas ===== 48 | 49 | const UrlTransformerSchema = z 50 | .function(z.tuple([NativeUrlSchema, OpenUrlOptionsSchema])) 51 | .returns(z.union([z.string(), NativeUrlSchema])) 52 | .identifier("UrlTransformer"); 53 | 54 | const UrlTransformSpecificationSchema = z 55 | .union([z.string(), NativeUrlSchema, UrlTransformerSchema]) 56 | .identifier("UrlTransformSpecification"); 57 | 58 | // ===== Matcher Schemas ===== 59 | const UrlMatcherFunctionSchema = z 60 | .function(z.tuple([NativeUrlSchema, OpenUrlOptionsSchema])) 61 | .returns(z.boolean()) 62 | .identifier("UrlMatcherFunction"); 63 | 64 | const UrlMatcherSchema = z 65 | .union([z.string(), RegexpSchema, UrlMatcherFunctionSchema]) 66 | .identifier("UrlMatcher"); 67 | 68 | const UrlMatcherPatternSchema = z.union([ 69 | UrlMatcherSchema, 70 | z.array(UrlMatcherSchema), 71 | ]); 72 | 73 | // ===== Browser Schemas ===== 74 | 75 | const appTypes = ["appName", "bundleId", "path", "none"] as const; 76 | 77 | export type AppType = (typeof appTypes)[number]; 78 | 79 | const BrowserConfigSchema = z 80 | .object({ 81 | name: z.string(), 82 | appType: z.enum(appTypes).optional(), 83 | openInBackground: z.boolean().optional(), 84 | profile: z.string().optional(), 85 | args: z.array(z.string()).optional(), 86 | }) 87 | .identifier("BrowserConfig") 88 | .describe("A browser or app to open for urls"); 89 | 90 | export const BrowserConfigStrictSchema = z.object({ 91 | name: z.string(), 92 | appType: z.enum(appTypes), 93 | openInBackground: z.boolean().optional(), 94 | profile: z.string(), 95 | args: z.array(z.string()), 96 | url: z.string(), 97 | }); 98 | 99 | const BrowserResolverSchema = z 100 | .function(z.tuple([NativeUrlSchema, OpenUrlOptionsSchema])) 101 | .returns(z.union([z.string(), BrowserConfigSchema])) 102 | .identifier("BrowserResolver"); 103 | 104 | export const BrowserSpecificationSchema = z 105 | .union([z.null(), z.string(), BrowserConfigSchema, BrowserResolverSchema]) 106 | .identifier("BrowserSpecification"); 107 | 108 | // ===== Rule Schemas ===== 109 | const RewriteRuleSchema = z 110 | .object({ 111 | match: UrlMatcherPatternSchema, 112 | url: UrlTransformSpecificationSchema, 113 | }) 114 | .describe( 115 | "A rewrite rule contains a matcher and a url. If the matcher matches when opening a url, the url will be rewritten to the returnedurl in the rewrite rule." 116 | ) 117 | .identifier("UrlRewriteRule"); 118 | 119 | const HandlerRuleSchema = z 120 | .object({ 121 | match: UrlMatcherPatternSchema, 122 | browser: BrowserSpecificationSchema, 123 | }) 124 | .describe( 125 | "A handler contains a matcher and a browser. If the matcher matches when opening a url, the browser in the handler will be opened." 126 | ) 127 | .identifier("BrowserHandler"); 128 | 129 | // ===== Configuration Schemas ===== 130 | const ConfigOptionsSchema = z 131 | .object({ 132 | urlShorteners: z.array(z.string()).optional(), 133 | logRequests: z.boolean().optional().describe("Log to file on disk"), 134 | checkForUpdates: z.boolean().optional().describe("Check for updates"), 135 | }) 136 | .identifier("ConfigOptions"); 137 | 138 | /** 139 | * @internal - don't export this schema as a type 140 | */ 141 | export const ConfigSchema = z 142 | .object({ 143 | defaultBrowser: BrowserSpecificationSchema.describe( 144 | "The default browser or app to open for urls where no other handler" 145 | ), 146 | options: ConfigOptionsSchema.optional(), 147 | rewrite: z 148 | .array(RewriteRuleSchema) 149 | .optional() 150 | .describe( 151 | "An array of rewriter rules that can change the url being opened" 152 | ), 153 | handlers: z 154 | .array(HandlerRuleSchema) 155 | .optional() 156 | .describe( 157 | "An array of handlers to select which browser or app to open for urls" 158 | ), 159 | }) 160 | .describe( 161 | ` 162 | This represents the full \`~/.finicky.js\` configuration object 163 | 164 | Example usage: 165 | 166 | \`\`\`js 167 | export default = { 168 | defaultBrowser: "Google Chrome", 169 | options: { 170 | logRequests: false 171 | }, 172 | handlers: [{ 173 | match: "example.com*", 174 | browser: "Firefox" 175 | }] 176 | } 177 | \`\`\` 178 | ` 179 | ); 180 | 181 | export type UrlTransformSpecification = z.infer< 182 | typeof UrlTransformSpecificationSchema 183 | >; 184 | export type UrlTransformer = z.infer; 185 | 186 | export type UrlMatcherFunction = z.infer; 187 | export type UrlMatcher = z.infer; 188 | export type UrlMatcherPattern = z.infer; 189 | export type BrowserConfigStrict = z.infer; 190 | 191 | export type BrowserConfig = z.infer; 192 | export type BrowserResolver = z.infer; 193 | export type BrowserSpecification = z.infer; 194 | 195 | export type RewriteRule = z.infer; 196 | export type HandlerRule = z.infer; 197 | 198 | export type ConfigOptions = z.infer; 199 | export type Config = z.infer; 200 | -------------------------------------------------------------------------------- /packages/config-api/src/index.ts: -------------------------------------------------------------------------------- 1 | import "core-js/features/url"; 2 | import "core-js/features/url-search-params"; 3 | import { 4 | ConfigSchema, 5 | Config, 6 | OpenUrlOptions, 7 | ProcessInfo, 8 | BrowserSpecificationSchema, 9 | BrowserSpecification, 10 | UrlTransformSpecification, 11 | UrlMatcherPattern, 12 | BrowserConfig, 13 | BrowserConfigStrict, 14 | AppType, 15 | } from "./configSchema"; 16 | import * as utilities from "./utilities"; 17 | import { matchWildcard } from "./wildcard"; 18 | import { fromError } from "zod-validation-error"; 19 | import { isLegacyURLObject, legacyURLObjectToString } from "./legacyURLObject"; 20 | import { FinickyURL } from "./FinickyURL"; 21 | export { utilities }; 22 | 23 | export function validateConfig(config: object): config is Config { 24 | if (!config) { 25 | console.error( 26 | "Could not find configuration object, please check your config file and make sure it has a default export." 27 | ); 28 | return false; 29 | } 30 | 31 | try { 32 | ConfigSchema.parse(config); 33 | return true; 34 | } catch (ex) { 35 | // Don't log parsing errors in test environment as they are expected 36 | if (process.env.NODE_ENV !== "test") { 37 | console.error(fromError(ex).toString()); 38 | } 39 | return false; 40 | } 41 | } 42 | 43 | export function getConfiguration(namespace: string): Config { 44 | const namespaceObj = (self as any)[namespace]; 45 | if (namespaceObj) { 46 | if (namespaceObj.default) { 47 | return namespaceObj.default; 48 | } else { 49 | console.warn( 50 | "No default export found for configuration namespace, using legacy configuration. Please update your configuration to use `export default { ... }` instead of `module.exports = { ... }` syntax. https://github.com/johnste/finicky/wiki/Use-Modern-ECMAScript-Module-Syntax" 51 | ); 52 | return namespaceObj; 53 | } 54 | } 55 | 56 | throw new Error( 57 | "Could not find configuration object, please check your config file and make sure it has a default export." 58 | ); 59 | } 60 | 61 | export function getOption( 62 | option: keyof Config["options"], 63 | config: Config, 64 | defaultValue: T 65 | ): T | K { 66 | return config.options && option in config.options 67 | ? (config.options[option] as K) 68 | : defaultValue; 69 | } 70 | 71 | export function getConfigState(config: Config) { 72 | return { 73 | handlers: config.handlers?.length || 0, 74 | rewrites: config.rewrite?.length || 0, 75 | defaultBrowser: 76 | resolveBrowser(config.defaultBrowser, new URL("https://example.com"), { 77 | opener: null, 78 | })?.name || "None", 79 | }; 80 | } 81 | 82 | export function openUrl( 83 | urlString: string, 84 | opener: ProcessInfo | null, 85 | config: object 86 | ) { 87 | if (!validateConfig(config)) { 88 | throw new Error("Invalid config"); 89 | } 90 | 91 | let url = new FinickyURL(urlString, opener); 92 | 93 | const options: OpenUrlOptions = { 94 | opener: opener, 95 | }; 96 | 97 | let error: string | undefined; 98 | 99 | try { 100 | if (config.rewrite) { 101 | for (const rewrite of config.rewrite) { 102 | if (isMatch(rewrite.match, url, options)) { 103 | url = rewriteUrl(rewrite.url, url, options); 104 | } 105 | } 106 | } 107 | 108 | if (config.handlers) { 109 | for (const [index, handler] of config.handlers.entries()) { 110 | if (isMatch(handler.match, url, options)) { 111 | return { 112 | browser: resolveBrowser(handler.browser, url, options), 113 | }; 114 | } 115 | } 116 | } 117 | } catch (ex: unknown) { 118 | error = ex instanceof Error ? ex.message : String(ex); 119 | } 120 | 121 | const browser = resolveBrowser(config.defaultBrowser, url, options); 122 | 123 | return { 124 | browser, 125 | error, 126 | }; 127 | } 128 | 129 | export function createBrowserConfig( 130 | browser: string | BrowserConfig | null 131 | ): Omit { 132 | const defaults = { 133 | appType: "appName" as const, 134 | openInBackground: undefined, 135 | profile: "", 136 | args: [], 137 | }; 138 | 139 | if (browser === null) { 140 | return { 141 | ...defaults, 142 | name: "", 143 | appType: "none" as const, 144 | }; 145 | } 146 | 147 | if (typeof browser === "string") { 148 | const browserInfo = resolveBrowserInfo(browser); 149 | 150 | return { 151 | ...defaults, 152 | ...browserInfo, 153 | }; 154 | } 155 | 156 | return { ...defaults, ...browser }; 157 | } 158 | 159 | export function resolveBrowserInfo( 160 | browser: string 161 | ): Pick { 162 | const [name, profile] = browser.split(":"); 163 | const appType = autodetectAppStringType(name); 164 | 165 | return { name, appType, profile: profile || "" }; 166 | } 167 | 168 | export function resolveBrowser( 169 | browser: BrowserSpecification, 170 | url: URL | FinickyURL, 171 | options: OpenUrlOptions 172 | ): BrowserConfigStrict { 173 | const config = 174 | typeof browser === "function" ? browser(url, options) : browser; 175 | 176 | if (config === undefined) { 177 | throw new Error( 178 | JSON.stringify( 179 | { 180 | message: "Browser config cannot be undefined", 181 | error: "Browser config must be a string, object, or null", 182 | }, 183 | null, 184 | 2 185 | ) 186 | ); 187 | } 188 | 189 | try { 190 | BrowserSpecificationSchema.parse(config); 191 | 192 | const browserConfig = createBrowserConfig(config); 193 | const finalConfig = { ...browserConfig, url: url.href }; 194 | 195 | return finalConfig; 196 | } catch (ex: unknown) { 197 | throw new Error( 198 | JSON.stringify( 199 | { 200 | message: "Invalid browser option", 201 | browser: config, 202 | error: fromError(ex).toString(), 203 | }, 204 | null, 205 | 2 206 | ) 207 | ); 208 | } 209 | } 210 | 211 | export function autodetectAppStringType(app: string | null): AppType { 212 | let appType: AppType = "appName"; 213 | 214 | if (app === null) { 215 | appType = "none"; 216 | } else if (/^[a-zA-Z0-9 ]+$/.test(app)) { 217 | appType = "appName"; 218 | } 219 | 220 | // The bundle ID string must contain only alphanumeric characters (A-Z, a-z, 0-9), hyphen (-), and period (.). 221 | // https://help.apple.com/xcode/mac/current/#/deve70ea917b 222 | else if (/^[a-zA-Z0-9.-]+$/.test(app)) { 223 | appType = "bundleId"; 224 | } 225 | 226 | // The app path should be an absolute path to an app, e.g. /Applications/Google Chrome.app or relative to 227 | // the user's home directory, e.g. ~/Applications/Google Chrome.app 228 | else if (/^(~?(?:\/[^/\n]+)+\/[^/\n]+\.app)$/.test(app)) { 229 | appType = "path"; 230 | } 231 | 232 | return appType; 233 | } 234 | 235 | export function rewriteUrl( 236 | rewrite: UrlTransformSpecification, 237 | url: URL | FinickyURL, 238 | options: OpenUrlOptions 239 | ): FinickyURL { 240 | if (rewrite instanceof FinickyURL) { 241 | return rewrite; 242 | } 243 | 244 | if (typeof rewrite === "string") { 245 | return new FinickyURL(rewrite, options.opener || null); 246 | } 247 | 248 | if (typeof rewrite === "function") { 249 | const result = rewrite(url, options); 250 | return rewriteUrl(result, url, options); 251 | } 252 | 253 | // Convert URL to FinickyURL if it's not already one 254 | if (rewrite instanceof URL) { 255 | return new FinickyURL(rewrite.href, options.opener || null); 256 | } 257 | 258 | if (isLegacyURLObject(rewrite)) { 259 | return new FinickyURL( 260 | legacyURLObjectToString(rewrite), 261 | options.opener || null 262 | ); 263 | } 264 | 265 | return url as FinickyURL; 266 | } 267 | 268 | export function isMatch( 269 | match: UrlMatcherPattern, 270 | url: URL | FinickyURL, 271 | options: OpenUrlOptions 272 | ): boolean { 273 | if (Array.isArray(match)) { 274 | return match.some((m) => isMatch(m, url, options)); 275 | } 276 | 277 | if (typeof match === "string") { 278 | if (match === "") { 279 | return false; // Empty string should not match anything 280 | } 281 | 282 | return matchWildcard(match, url.href); 283 | } 284 | 285 | if (match instanceof RegExp) { 286 | return match.test(url.href); 287 | } 288 | 289 | if (typeof match === "function") { 290 | return match(url, options); 291 | } 292 | 293 | return false; 294 | } 295 | -------------------------------------------------------------------------------- /packages/config-api/src/legacyURLObject.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Legacy FinickyURL type used for backward compatibility 3 | */ 4 | export type LegacyURLObject = { 5 | username?: string; 6 | host: string; 7 | protocol?: string; 8 | pathname?: string; 9 | search?: string; 10 | password?: string; 11 | port?: number; 12 | hash?: string; 13 | }; 14 | 15 | /** 16 | * Converts a LegacyURLObject to a URL string 17 | * @param urlObj The LegacyURLObject to convert 18 | * @returns A URL string representation 19 | */ 20 | export function legacyURLObjectToString(urlObj: LegacyURLObject): string { 21 | return `${urlObj.protocol ? urlObj.protocol.replace(":", "") : "https"}://${ 22 | urlObj.username 23 | }${urlObj.password ? ":" + urlObj.password : ""}${ 24 | urlObj.username || urlObj.password ? "@" : "" 25 | }${urlObj.host}${urlObj.port ? ":" + urlObj.port : ""}${urlObj.pathname}${ 26 | urlObj.search ? "?" + urlObj.search : "" 27 | }${urlObj.hash ? "#" + urlObj.hash : ""}`; 28 | } 29 | 30 | /** 31 | * Converts a standard URL object to a FinickyURL format 32 | * @param url The URL object to convert 33 | * @returns A FinickyURL object 34 | */ 35 | export function URLtoLegacyURLObject(url: URL): LegacyURLObject { 36 | return { 37 | username: url.username, 38 | host: url.hostname, 39 | protocol: url.protocol.replace(":", ""), 40 | pathname: url.pathname, 41 | search: url.search.replace("?", ""), 42 | password: url.password, 43 | port: url.port ? parseInt(url.port, 10) : undefined, 44 | hash: url.hash.replace("#", ""), 45 | }; 46 | } 47 | 48 | /** 49 | * Type guard to check if an object is a LegacyURLObject 50 | * @param obj The object to check 51 | * @returns True if the object matches LegacyURLObject structure 52 | */ 53 | export function isLegacyURLObject(obj: unknown): obj is LegacyURLObject { 54 | if (!obj || typeof obj !== "object") return false; 55 | 56 | const urlObj = obj as Partial; 57 | 58 | return ( 59 | (typeof urlObj.username === "string" || urlObj.username === undefined) && 60 | typeof urlObj.host === "string" && 61 | (typeof urlObj.protocol === "string" || urlObj.protocol === undefined) && 62 | (typeof urlObj.pathname === "string" || urlObj.pathname === undefined) && 63 | (typeof urlObj.search === "string" || urlObj.search === undefined) && 64 | (typeof urlObj.password === "string" || urlObj.password === undefined) && 65 | (typeof urlObj.port === "number" || urlObj.port === undefined) && 66 | (typeof urlObj.hash === "string" || urlObj.hash === undefined) 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /packages/config-api/src/utilities.ts: -------------------------------------------------------------------------------- 1 | type Matcher = string | RegExp; 2 | 3 | export function matchHostnames(matchers: Matcher | Array) { 4 | matchers = Array.isArray(matchers) ? matchers : [matchers]; 5 | 6 | matchers.forEach((matcher) => { 7 | if (matcher instanceof RegExp || typeof matcher === "string") { 8 | return; 9 | } 10 | throw new Error(`Unrecognized hostname type "${typeof matcher}"`); 11 | }); 12 | 13 | return function (url: URL) { 14 | const hostname = url.hostname; 15 | 16 | if (!hostname) { 17 | console.warn("No hostname available for", url.href); 18 | return false; 19 | } 20 | 21 | return (matchers as Array).some((matcher) => { 22 | if (matcher instanceof RegExp) { 23 | return matcher.test(hostname); 24 | } else if (typeof matcher === "string") { 25 | return matcher === hostname; 26 | } 27 | 28 | return false; 29 | }); 30 | }; 31 | } 32 | 33 | export function matchDomains(matchers: Matcher | Array) { 34 | console.warn( 35 | "finicky.matchDomains is deprecated. Use finicky.matchHostnames instead." 36 | ); 37 | return matchHostnames(matchers); 38 | } 39 | 40 | export function getBattery() { 41 | console.error( 42 | "finicky.getBattery is unavailable. Use finicky.getPowerInfo instead. Returning dummy values." 43 | ); 44 | return { 45 | isCharging: false, 46 | isPluggedIn: false, 47 | chargePercentage: 0, 48 | }; 49 | } 50 | 51 | export function notify() { 52 | console.error( 53 | "finicky.notify is unavailable. Use console.log, console.warn or console.error instead." 54 | ); 55 | return undefined; 56 | } 57 | -------------------------------------------------------------------------------- /packages/config-api/src/wildcard.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { matchWildcard } from "./wildcard"; 3 | 4 | describe("matchWildcard", () => { 5 | describe("basic wildcard matching", () => { 6 | it("matches example.com* pattern with various protocols", () => { 7 | const pattern = "example.com*"; 8 | expect(matchWildcard(pattern, "https://example.com")).toBe(true); 9 | expect(matchWildcard(pattern, "http://example.com")).toBe(true); 10 | expect(matchWildcard(pattern, "mailto:example.com")).toBe(true); 11 | expect(matchWildcard(pattern, "ftp://example.com")).toBe(true); 12 | expect(matchWildcard(pattern, "https://example.com/path")).toBe(true); 13 | expect(matchWildcard(pattern, "https://example.com?query=123")).toBe( 14 | true 15 | ); 16 | expect(matchWildcard(pattern, "https://example.com#fragment")).toBe(true); 17 | }); 18 | 19 | it("does not match non-matching domains", () => { 20 | const pattern = "example.com*"; 21 | expect(matchWildcard(pattern, "https://notexample.com")).toBe(false); 22 | expect(matchWildcard(pattern, "https://sub.different.com")).toBe(false); 23 | }); 24 | }); 25 | 26 | describe("wildcard positions", () => { 27 | it("matches wildcards at the start", () => { 28 | const pattern = "*.example.com"; 29 | expect(matchWildcard(pattern, "https://sub.example.com")).toBe(true); 30 | expect(matchWildcard(pattern, "https://another.sub.example.com")).toBe( 31 | true 32 | ); 33 | expect(matchWildcard(pattern, "https://example.com")).toBe(false); 34 | }); 35 | 36 | it("matches wildcards in the middle", () => { 37 | const pattern = "example.*/path"; 38 | expect(matchWildcard(pattern, "https://example.com/path")).toBe(true); 39 | expect(matchWildcard(pattern, "https://example.org/path")).toBe(true); 40 | expect(matchWildcard(pattern, "https://example.com/other")).toBe(false); 41 | }); 42 | 43 | it("matches multiple wildcards", () => { 44 | const pattern = "*.example.*/path/*"; 45 | expect(matchWildcard(pattern, "https://sub.example.com/path/test")).toBe( 46 | true 47 | ); 48 | expect( 49 | matchWildcard(pattern, "https://sub.example.org/path/foo/bar") 50 | ).toBe(true); 51 | expect(matchWildcard(pattern, "https://sub.example.com/other/test")).toBe( 52 | false 53 | ); 54 | }); 55 | }); 56 | 57 | describe("escaped wildcards", () => { 58 | it("matches literal asterisks when escaped", () => { 59 | const pattern = "example\\*.com"; 60 | expect(matchWildcard(pattern, "https://example*.com")).toBe(true); 61 | expect(matchWildcard(pattern, "https://example.com")).toBe(false); 62 | }); 63 | 64 | it("combines escaped and unescaped wildcards", () => { 65 | const pattern = "*example\\*.com/*"; 66 | expect(matchWildcard(pattern, "https://test.example*.com/path")).toBe( 67 | true 68 | ); 69 | expect(matchWildcard(pattern, "https://example*.com/anything")).toBe( 70 | true 71 | ); 72 | expect(matchWildcard(pattern, "https://example.com/path")).toBe(false); 73 | }); 74 | }); 75 | 76 | describe("special cases", () => { 77 | it("handles patterns with special regex characters", () => { 78 | const pattern = "example.com/path?*"; 79 | expect(matchWildcard(pattern, "https://example.com/path?query=123")).toBe( 80 | true 81 | ); 82 | expect(matchWildcard(pattern, "https://example.com/path")).toBe(false); 83 | }); 84 | 85 | it("handles full URLs with query parameters and fragments", () => { 86 | const pattern = "*.com/*#*"; 87 | expect(matchWildcard(pattern, "https://example.com/path#fragment")).toBe( 88 | true 89 | ); 90 | expect( 91 | matchWildcard( 92 | pattern, 93 | "https://sub.example.com/path?query=123#fragment" 94 | ) 95 | ).toBe(true); 96 | }); 97 | 98 | it("handles invalid patterns gracefully", () => { 99 | const pattern = "[invalid.pattern"; 100 | expect(matchWildcard(pattern, "https://example.com")).toBe(false); 101 | }); 102 | }); 103 | 104 | describe("additional edge cases", () => { 105 | it("handles exact matches", () => { 106 | const pattern = "https://example.com"; 107 | expect(matchWildcard(pattern, "https://example.com")).toBe(true); 108 | expect(matchWildcard(pattern, "http://example.com")).toBe(false); 109 | }); 110 | 111 | it("handles multiple TLD wildcards", () => { 112 | const pattern = "*.google.*"; 113 | expect(matchWildcard(pattern, "https://mail.google.com")).toBe(true); 114 | expect(matchWildcard(pattern, "https://docs.google.co.uk")).toBe(true); 115 | expect(matchWildcard(pattern, "https://google.com")).toBe(false); 116 | }); 117 | 118 | it("handles complex subdomain patterns", () => { 119 | const pattern = "https://*.github.io/*"; 120 | expect(matchWildcard(pattern, "https://user1.github.io/repo")).toBe(true); 121 | expect(matchWildcard(pattern, "https://org.github.io/docs/api")).toBe( 122 | true 123 | ); 124 | expect(matchWildcard(pattern, "https://github.io/test")).toBe(false); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /packages/config-api/src/wildcard.ts: -------------------------------------------------------------------------------- 1 | export function matchWildcard(pattern: string, str: string): boolean { 2 | try { 3 | if (!pattern.includes("*")) { 4 | return pattern === str; 5 | } 6 | 7 | // First handle escaped asterisks by temporarily replacing them 8 | const ESCAPED_ASTERISK_PLACEHOLDER = "\u0000"; 9 | const patternWithEscapedAsterisks = pattern.replace( 10 | /\\\*/g, 11 | ESCAPED_ASTERISK_PLACEHOLDER 12 | ); 13 | 14 | // Then escape all special regex chars except asterisk 15 | let escaped = patternWithEscapedAsterisks.replace( 16 | /[.+?^${}()|[\]\\]/g, 17 | "\\$&" 18 | ); 19 | 20 | // If pattern doesn't start with a protocol, make it match common protocols 21 | if (!/^\w+:/.test(pattern)) { 22 | // If pattern starts with *, don't add protocol matching 23 | if (!pattern.startsWith("*")) { 24 | escaped = `(?:https?:|ftp:|mailto:|file:|tel:|sms:|data:)?(?:\/\/)?${escaped}`; 25 | } 26 | } else { 27 | // If it's a protocol pattern, make sure to escape the forward slashes 28 | escaped = escaped.replace(/\//g, "\\/"); 29 | // If it ends with //, make it match anything after 30 | if (escaped.endsWith("\\/\\/")) { 31 | escaped += ".*"; 32 | } 33 | } 34 | 35 | // Replace unescaped asterisks with non-greedy match to prevent over-matching 36 | const regexPattern = escaped.replace(/\*/g, ".*?"); 37 | 38 | // Finally, restore the escaped asterisks as literal asterisks 39 | const finalPattern = regexPattern.replace( 40 | new RegExp(ESCAPED_ASTERISK_PLACEHOLDER, "g"), 41 | "\\*" 42 | ); 43 | 44 | // Add start and end anchors to ensure full string match 45 | const regex = new RegExp(`^${finalPattern}$`); 46 | 47 | return regex.test(str); 48 | } catch (error) { 49 | console.warn("Invalid wildcard pattern:", pattern, error); 50 | return false; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/config-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["ES2017", "DOM"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "ESNext", /* Specify what module code is generated. */ 29 | "rootDir": ".", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ 40 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 41 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 42 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 43 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ 44 | // "resolveJsonModule": true, /* Enable importing .json files. */ 45 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 46 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 47 | 48 | /* JavaScript Support */ 49 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 50 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 51 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 52 | 53 | /* Emit */ 54 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 55 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 56 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 57 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "noEmit": true, /* Disable emitting files from a compilation. */ 60 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 61 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 62 | // "removeComments": true, /* Disable emitting comments. */ 63 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 64 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 65 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 66 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 67 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 68 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 69 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 70 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 71 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 72 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 73 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 74 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 80 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 81 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 82 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 83 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 84 | 85 | /* Type Checking */ 86 | "strict": true, /* Enable all strict type-checking options. */ 87 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 88 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 89 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 90 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 91 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 92 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ 93 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 94 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 95 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 96 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 97 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 98 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 99 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 100 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 101 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 102 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 103 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 104 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 105 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 106 | 107 | /* Completeness */ 108 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 109 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/finicky-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .vite -------------------------------------------------------------------------------- /packages/finicky-ui/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["svelte.svelte-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/finicky-ui/README.md: -------------------------------------------------------------------------------- 1 | # Finicky UI 2 | 3 | This package provides the contents of the app window for Finicky. 4 | -------------------------------------------------------------------------------- /packages/finicky-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Finicky 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/finicky-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "finicky-ui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" 11 | }, 12 | "devDependencies": { 13 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 14 | "@tsconfig/svelte": "^5.0.4", 15 | "svelte": "^5.20.2", 16 | "svelte-check": "^4.1.4", 17 | "typescript": "~5.7.2", 18 | "vite": "^6.2.5" 19 | }, 20 | "dependencies": { 21 | "svelte-routing": "^2.13.0" 22 | }, 23 | "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" 24 | } 25 | -------------------------------------------------------------------------------- /packages/finicky-ui/public/finicky-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnste/finicky/3163573be455028e735009b55430333d4cba1585/packages/finicky-ui/public/finicky-icon.png -------------------------------------------------------------------------------- /packages/finicky-ui/public/finicky-logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnste/finicky/3163573be455028e735009b55430333d4cba1585/packages/finicky-ui/public/finicky-logo-light.png -------------------------------------------------------------------------------- /packages/finicky-ui/public/finicky-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnste/finicky/3163573be455028e735009b55430333d4cba1585/packages/finicky-ui/public/finicky-logo.png -------------------------------------------------------------------------------- /packages/finicky-ui/src/App.svelte: -------------------------------------------------------------------------------- 1 | 86 | 87 | 88 |
89 | 90 |
91 |
92 | 93 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 112 | 113 |
114 |
115 | 122 |
123 |
124 | 125 | 175 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 3 | sans-serif; 4 | line-height: 1.5; 5 | font-weight: 400; 6 | 7 | color-scheme: dark; 8 | color: rgb(204, 204, 204); 9 | background-color: #1a1a1a; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | } 16 | 17 | :root { 18 | --bg-primary: #1a1a1a; 19 | --bg-hover: rgba(255, 255, 255, 0.1); 20 | --bg-nav: #1a1a1a; 21 | --text-primary: #ffffff; 22 | --text-secondary: rgb(204, 204, 204); 23 | --border-color: #333333; 24 | --status-bg: #2d3436; 25 | --log-bg: #232323; 26 | --log-header-bg: #2a2a2a; 27 | --button-bg: #3d3d3d; 28 | --button-hover: #4a4a4a; 29 | --log-error: #ff5252; 30 | --log-warning: #ffb74d; 31 | --log-debug: #b654ff; 32 | --nav-active: transparent; 33 | --nav-hover: transparent; 34 | --accent-color: #b654ff; 35 | } 36 | 37 | @media (prefers-color-scheme: light) { 38 | :root { 39 | --bg-primary: #ffffff; 40 | --bg-hover: rgba(0, 0, 0, 0.04); 41 | --bg-nav: #f3f3f3; 42 | --text-primary: #333333; 43 | --text-secondary: #737373; 44 | --border-color: #dddddd; 45 | --status-bg: #e8f5e9; 46 | --log-bg: #fafafa; 47 | --log-header-bg: #f5f5f5; 48 | --button-bg: #ffffff; 49 | --button-hover: #f0f0f0; 50 | --log-error: #d32f2f; 51 | --log-warning: #f57c00; 52 | --log-debug: #b654ff; 53 | --nav-active: #ffffff; 54 | --nav-hover: #e8e8e8; 55 | } 56 | } 57 | 58 | html, 59 | body { 60 | height: 100%; 61 | margin: 0; 62 | padding: 0; 63 | } 64 | 65 | body { 66 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto; 67 | background: var(--bg-primary); 68 | color: var(--text-secondary); 69 | min-width: 300px; 70 | } 71 | 72 | a { 73 | color: var(--text-secondary); 74 | } 75 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/About.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 |
23 |
24 | 37 |
38 |
39 | 40 |
41 | Developer mode 42 |
43 |

{version}

44 | 45 | 52 |
53 |

54 | Created by John Sterling 57 |
58 | Icon designed by 59 | @uetchy 60 |

61 |
62 | {#if isDevMode} 63 | 64 | {/if} 65 |
66 | 67 | 147 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/DebugMessageToggle.svelte: -------------------------------------------------------------------------------- 1 | 93 | 94 | 98 | 99 | 131 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/LogContent.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | {#each messageBuffer as entry} 13 | {#if showDebug || entry.level.toLowerCase() !== "debug"} 14 |
  1. 15 | 16 | {new Date(entry.time).toLocaleTimeString()} 17 | 18 | 20 |
    21 | {#each formatLogEntry(entry) as part} 22 | {#if part.type === "url"} 23 | {part.content} 26 | {:else} 27 | {part.content} 28 | {/if} 29 | {/each} 30 |
    31 |
  2. 32 | {/if} 33 | {/each} 34 |
35 | 36 | 111 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/LogViewer.svelte: -------------------------------------------------------------------------------- 1 | 65 | 66 |
67 |
68 |

Logs

69 |
70 | 73 | 74 | 77 |
78 |
79 | 80 | 81 |
82 | 83 | 146 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/StartPage.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 |
13 | {#if hasConfig} 14 |
15 |

Loaded Configuration

16 |
    17 |
  • Config Path: {configPath || "Not set"}
  • 18 |
19 |
20 | {:else} 21 |
22 |

No Configuration Found

23 |

Create a configuration file to customize your browser behavior.

24 |

25 | 29 | Learn how to get started 30 | 31 |

32 |
33 | {/if} 34 | 35 | {#if numErrors > 0} 36 |
37 |

Errors

38 |

39 | Finicky found {numErrors} errors while evaluating your configuration. 40 |

41 | Troubleshooting 42 |
43 | {/if} 44 | 45 | {#if updateInfo} 46 | {#if updateInfo.hasUpdate} 47 |
48 |

New Version Available

49 |

50 | A new version "{updateInfo.version}" of Finicky is available to 51 | download. 52 |

53 |

54 | 55 | View release notes 56 | 57 |
58 | 59 | Download the latest version 60 | 61 |

62 |
63 | {:else if updateInfo.updateCheckEnabled} 64 |
65 |

Finicky is up to date

66 |
67 | {:else} 68 |
69 |

Update check is disabled

70 | 71 | Check releases 72 | 73 |
74 | {/if} 75 | {/if} 76 |
77 |
78 | 79 | 169 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/TabBar.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 |
30 | 47 |
48 | 49 | 159 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/icons/About.svelte: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/icons/General.svelte: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/components/icons/Troubleshoot.svelte: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "svelte"; 2 | import App from "./App.svelte"; 3 | import "./reset.css"; 4 | import "./app.css"; 5 | 6 | const app = mount(App, { 7 | target: document.getElementById("app")!, 8 | }); 9 | 10 | export default app; 11 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/reset.css: -------------------------------------------------------------------------------- 1 | /* 1. Use a more-intuitive box-sizing model */ 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: border-box; 6 | } 7 | 8 | /* 2. Remove default margin */ 9 | * { 10 | margin: 0; 11 | } 12 | 13 | /* 3. Enable keyword animations */ 14 | @media (prefers-reduced-motion: no-preference) { 15 | html { 16 | interpolate-size: allow-keywords; 17 | } 18 | } 19 | 20 | body { 21 | /* 4. Add accessible line-height */ 22 | line-height: 1.5; 23 | /* 5. Improve text rendering */ 24 | -webkit-font-smoothing: antialiased; 25 | } 26 | 27 | /* 6. Improve media defaults */ 28 | img, 29 | picture, 30 | video, 31 | canvas, 32 | svg { 33 | display: block; 34 | max-width: 100%; 35 | } 36 | 37 | /* 7. Inherit fonts for form controls */ 38 | input, 39 | button, 40 | textarea, 41 | select { 42 | font: inherit; 43 | } 44 | 45 | /* 8. Avoid text overflows */ 46 | p, 47 | h1, 48 | h2, 49 | h3, 50 | h4, 51 | h5, 52 | h6 { 53 | overflow-wrap: break-word; 54 | } 55 | 56 | /* 9. Improve line wrapping */ 57 | p { 58 | text-wrap: pretty; 59 | } 60 | h1, 61 | h2, 62 | h3, 63 | h4, 64 | h5, 65 | h6 { 66 | text-wrap: balance; 67 | } 68 | 69 | /* 70 | 10. Create a root stacking context 71 | */ 72 | #root, 73 | #__next { 74 | isolation: isolate; 75 | } 76 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface LogEntry { 2 | level: string; 3 | msg: string; 4 | time: string; 5 | error?: string; 6 | [key: string]: any; // Allow for additional dynamic fields 7 | } 8 | 9 | declare global { 10 | interface Window { 11 | finicky: { 12 | sendMessage: (msg: any) => void; 13 | receiveMessage: (msg: any) => void; 14 | }; 15 | webkit?: { 16 | messageHandlers?: { 17 | finicky?: { 18 | postMessage: (msg: string) => void; 19 | }; 20 | }; 21 | }; 22 | } 23 | } 24 | 25 | export interface UpdateInfo { 26 | version: string; 27 | hasUpdate: boolean; 28 | updateCheckEnabled: boolean; 29 | downloadUrl: string; 30 | releaseUrl: string; 31 | } 32 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/utils/text.ts: -------------------------------------------------------------------------------- 1 | import type { LogEntry } from "../types"; 2 | 3 | export type TextPart = { 4 | type: "text" | "url"; 5 | content: string; 6 | }; 7 | 8 | /** 9 | * Splits text into parts, separating URLs from regular text 10 | * @param text The input text to process 11 | * @returns Array of text and URL parts 12 | */ 13 | export function splitTextAndUrls(text: string): TextPart[] { 14 | const urlRegex = /((https?|file):\/\/[^\s]+)/g; 15 | const parts: TextPart[] = []; 16 | let lastIndex = 0; 17 | let match; 18 | 19 | while ((match = urlRegex.exec(text)) !== null) { 20 | if (match.index > lastIndex) { 21 | parts.push({ 22 | type: "text", 23 | content: text.slice(lastIndex, match.index), 24 | }); 25 | } 26 | parts.push({ 27 | type: "url", 28 | content: match[0], 29 | }); 30 | lastIndex = match.index + match[0].length; 31 | } 32 | 33 | if (lastIndex < text.length) { 34 | parts.push({ 35 | type: "text", 36 | content: text.slice(lastIndex), 37 | }); 38 | } 39 | 40 | return parts; 41 | } 42 | 43 | /** 44 | * Formats a log entry into text parts with URLs properly handled 45 | * @param entry The log entry to format 46 | * @returns Array of text and URL parts 47 | */ 48 | export function formatLogEntry(entry: LogEntry): TextPart[] { 49 | let result = entry.msg; 50 | 51 | if (entry.error) { 52 | result += `\nError: ${entry.error}`; 53 | } 54 | 55 | // Add any additional fields 56 | const additionalFields = Object.entries(entry).filter( 57 | ([key]) => !["level", "msg", "time", "error"].includes(key) 58 | ); 59 | 60 | if (additionalFields.length > 0) { 61 | result += 62 | "\n" + 63 | additionalFields.map(([key, value]) => `${key}: ${value}`).join("\n"); 64 | } 65 | 66 | return splitTextAndUrls(result); 67 | } 68 | -------------------------------------------------------------------------------- /packages/finicky-ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/finicky-ui/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | }; 8 | -------------------------------------------------------------------------------- /packages/finicky-ui/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "resolveJsonModule": true, 8 | /** 9 | * Typecheck JS in `.svelte` and `.js` files by default. 10 | * Disable checkJs if you'd like to use dynamic types in JS. 11 | * Note that setting allowJs false does not prevent the use 12 | * of JS in `.svelte` files. 13 | */ 14 | "allowJs": true, 15 | "checkJs": true, 16 | "isolatedModules": true, 17 | "moduleDetection": "force" 18 | }, 19 | "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/finicky-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/finicky-ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/finicky-ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [svelte()], 7 | base: "finicky-assets://local/", 8 | build: { 9 | assetsDir: "assets", 10 | rollupOptions: { 11 | output: { 12 | assetFileNames: "[name][extname]", 13 | chunkFileNames: "[name].js", 14 | entryFileNames: "[name].js", 15 | }, 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on error 4 | set -e 5 | 6 | 7 | # Build config-api 8 | ( 9 | cd packages/config-api 10 | npm run build 11 | npm run generate-types 12 | 13 | # Copy config API to finicky 14 | cd ../../ 15 | cp packages/config-api/dist/finickyConfigAPI.js apps/finicky/src/assets/finickyConfigAPI.js 16 | ) 17 | 18 | # Build finicky-ui 19 | ( 20 | cd packages/finicky-ui 21 | npm run build 22 | 23 | # Copy finicky-ui dist to finicky 24 | cd ../../ 25 | 26 | # Ensure destination directory exists 27 | mkdir -p apps/finicky/src/assets/templates 28 | 29 | # Copy templates from dist to finicky app 30 | cp -r packages/finicky-ui/dist/* apps/finicky/src/assets/templates 31 | ) 32 | 33 | # Determine app name based on target architecture (for CI builds) 34 | if [ -n "$BUILD_TARGET_ARCH" ]; then 35 | APP_NAME="Finicky-${BUILD_TARGET_ARCH}.app" 36 | else 37 | APP_NAME="Finicky.app" 38 | fi 39 | 40 | 41 | # Build the application 42 | ( 43 | # Get build information 44 | COMMIT_HASH=$(git rev-parse --short HEAD) 45 | BUILD_DATE=$(date -u '+%Y-%m-%d %H:%M:%S UTC') 46 | API_HOST=$(cat .env | grep API_HOST | cut -d '=' -f 2) 47 | 48 | 49 | export CGO_CFLAGS="-mmacosx-version-min=12.0" 50 | export CGO_LDFLAGS="-mmacosx-version-min=12.0" 51 | 52 | cd apps/finicky 53 | mkdir -p build/${APP_NAME}/Contents/MacOS 54 | mkdir -p build/${APP_NAME}/Contents/Resources 55 | go build -C src \ 56 | -ldflags \ 57 | "-X 'finicky/version.commitHash=${COMMIT_HASH}' \ 58 | -X 'finicky/version.buildDate=${BUILD_DATE}' \ 59 | -X 'finicky/version.apiHost=${API_HOST}'" \ 60 | -o ../build/${APP_NAME}/Contents/MacOS/Finicky 61 | ) 62 | 63 | # Copy static assets 64 | cp packages/config-api/dist/finicky.d.ts apps/finicky/build/${APP_NAME}/Contents/Resources/finicky.d.ts 65 | cp -r apps/finicky/assets/* apps/finicky/build/${APP_NAME}/Contents/ 66 | 67 | # Only replace existing app if not in CI (BUILD_TARGET_ARCH not set) 68 | if [ -z "$BUILD_TARGET_ARCH" ]; then 69 | # Replace existing app 70 | rm -rf /Applications/Finicky.app 71 | cp -r apps/finicky/build/Finicky.app /Applications/ 72 | fi 73 | 74 | echo "Build complete ✨" -------------------------------------------------------------------------------- /scripts/gon-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": ["./apps/finicky/build/Finicky.app"], 3 | "bundle_id": "se.johnste.finicky", 4 | "apple_id": { 5 | "username": "john@johnste.se" 6 | }, 7 | "sign": { 8 | "application_identity": "Developer ID Application: John Sterling" 9 | }, 10 | "dmg": { 11 | "output_path": "dist/Finicky.dmg", 12 | "volume_name": "Finicky", 13 | "app_drop_link": [200, 0] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | ( 2 | cd packages/config-api 3 | npm ci 4 | ) 5 | 6 | ( 7 | cd packages/finicky-ui 8 | npm ci 9 | ) 10 | 11 | 12 | ( 13 | cd apps/finicky/src 14 | go mod tidy 15 | ) 16 | 17 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | ./scripts/build.sh 2 | mkdir -p dist 3 | export $(cat .env | xargs) && gon scripts/gon-config.json 4 | 5 | -------------------------------------------------------------------------------- /scripts/watch-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | fd . --type f packages/config-api packages/finicky-ui apps/finicky \ 4 | | entr -r sh -c 'clear && DEBUG=true ./scripts/build.sh && ./build/Finicky.app/Contents/MacOS/Finicky && echo "$?"' 5 | -------------------------------------------------------------------------------- /scripts/watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | fd . --type f packages/config-api packages/finicky-ui apps/finicky \ 4 | | entr -r sh -c 'clear && ./scripts/build.sh && echo "$?"' 5 | 6 | --------------------------------------------------------------------------------