├── .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 |
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 | [](https://GitHub.com/johnste/finicky/releases/)  
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 |
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 | -
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 |
32 | {/if}
33 | {/each}
34 |
35 |
36 |
111 |
--------------------------------------------------------------------------------
/packages/finicky-ui/src/components/LogViewer.svelte:
--------------------------------------------------------------------------------
1 |
65 |
66 |
67 |
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 |
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 |
63 | {:else if updateInfo.updateCheckEnabled}
64 |
65 |
Finicky is up to date
66 |
67 | {:else}
68 |
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 |
--------------------------------------------------------------------------------