Compare commits

..

20 Commits

Author SHA1 Message Date
Gregory Schier
c20c0eff32 Update entitlements.plist for 1Password shared lib 2025-12-11 09:22:27 -08:00
Gregory Schier
9d40949043 Fix warning: unused variable: window on non-mac OSs 2025-12-11 07:18:31 -08:00
Gregory Schier
d435337f2a Don't strip symbols hotfix 2025-12-11 06:49:06 -08:00
Gregory Schier
a32145c054 Merge branch 'hotfix/2025.9.3' 2025-12-11 06:32:35 -08:00
Gregory Schier
e0f547b93f Update tauri 2025-12-11 06:32:14 -08:00
Gregory Schier
5d4268d6a1 Merge branch 'hotfix/2025.9.3' 2025-12-11 06:00:47 -08:00
Gregory Schier
0a3506f81e Also move defaultValue out 2025-12-11 05:59:40 -08:00
Gregory Schier
375b2287b7 Merge branch 'hotfix/2025.9.3' 2025-12-11 05:54:23 -08:00
Gregory Schier
e72c1e68e5 Unify 1Password field back to static name 2025-12-11 05:48:19 -08:00
Gregory Schier
3484db3371 Default cert to open when just added 2025-12-10 15:08:59 -08:00
Gregory Schier
c4b559f34b Support client certificates (#319) 2025-12-10 13:54:22 -08:00
Mikhail Mamontov
ef1ba9b834 fix(gRPC): Cache descriptor pools to avoid re-reflection; add manual “Refresh Schema” to force re-fetch (#317) 2025-12-09 15:35:35 -08:00
Jake Oliver
846f4d9551 Update 1Password template to support the new Desktop authentication method (#316) 2025-12-09 14:50:08 -08:00
Gregory Schier
4780bfe41f Fix curl import: decode Unicode escape sequences in $'...' strings (#318)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 14:15:39 -08:00
Gregory Schier
d0d01b3897 Update license check to use status instead of type 2025-12-09 14:12:13 -08:00
Gregory Schier
fc1e8baa23 Catch any 4XX error on refresh token failure
https://feedback.yaak.app/p/folders-oauth2-refresh-token-issue
2025-12-09 14:08:31 -08:00
Gregory Schier
d35116c494 Add license handling for expired licenses 2025-12-09 13:51:02 -08:00
gschier
1d257b365b Deploying to main from @ mountain-loop/yaak@1076d57e8a 🚀 2025-12-09 18:15:05 +00:00
Gregory Schier
1076d57e8a Remove unused funding model entries from FUNDING.yml 2025-12-09 10:14:19 -08:00
Gregory Schier
1c93d5775f Shorter titles when using native titlebar 2025-12-06 06:47:34 -08:00
61 changed files with 1857 additions and 577 deletions

12
.github/FUNDING.yml vendored
View File

@@ -1,15 +1,3 @@
# These are supported funding model platforms
github: gschier
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: https://yaak.app/pricing

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@ dist-ssr
*.sln
*.sw?
.eslintcache
out
*.sqlite
*.sqlite-*

View File

@@ -22,7 +22,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)

162
package-lock.json generated
View File

@@ -61,7 +61,7 @@
],
"devDependencies": {
"@biomejs/biome": "^2.3.7",
"@tauri-apps/cli": "^2.9.1",
"@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.3.4",
"nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5",
@@ -3216,9 +3216,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.0.tgz",
"integrity": "sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -3226,9 +3226,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.1.tgz",
"integrity": "sha512-kKi2/WWsNXKoMdatBl4xrT7e1Ce27JvsetBVfWuIb6D3ep/Y0WO5SIr70yarXOSWam8NyDur4ipzjZkg6m7VDg==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -3242,23 +3242,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.9.1",
"@tauri-apps/cli-darwin-x64": "2.9.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.1",
"@tauri-apps/cli-linux-arm64-musl": "2.9.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.1",
"@tauri-apps/cli-linux-x64-gnu": "2.9.1",
"@tauri-apps/cli-linux-x64-musl": "2.9.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.1",
"@tauri-apps/cli-win32-x64-msvc": "2.9.1"
"@tauri-apps/cli-darwin-arm64": "2.9.6",
"@tauri-apps/cli-darwin-x64": "2.9.6",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
"@tauri-apps/cli-linux-arm64-musl": "2.9.6",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-musl": "2.9.6",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
"@tauri-apps/cli-win32-x64-msvc": "2.9.6"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.1.tgz",
"integrity": "sha512-sdwhtsE/6njD0AjgfYEj1JyxZH4SBmCJSXpRm6Ph5fQeuZD6MyjzjdVOrrtFguyREVQ7xn0Ujkwvbo01ULthNg==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
"cpu": [
"arm64"
],
@@ -3273,9 +3273,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.1.tgz",
"integrity": "sha512-c86g+67wTdI4TUCD7CaSd/13+oYuLQxVST4ZNJ5C+6i1kdnU3Us1L68N9MvbDLDQGJc9eo0pvuK6sCWkee+BzA==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
"integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
"cpu": [
"x64"
],
@@ -3290,9 +3290,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.1.tgz",
"integrity": "sha512-IrB3gFQmueQKJjjisOcMktW/Gh6gxgqYO419doA3YZ7yIV5rbE8ZW52Q3I4AO+SlFEyVYer5kpi066p0JBlLGw==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
"integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
"cpu": [
"arm"
],
@@ -3307,9 +3307,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.1.tgz",
"integrity": "sha512-Ke7TyXvu6HbWSkmVkFbbH19D3cLsd117YtXP/u9NIvSpYwKeFtnbpirrIUfPm44Q+PZFZ2Hvg8X9qoUiAK0zKw==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
"integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
"cpu": [
"arm64"
],
@@ -3324,9 +3324,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.1.tgz",
"integrity": "sha512-sGvy75sv55oeMulR5ArwPD28DsDQxqTzLhXCrpU9/nbFg/JImmI7k994YE9fr3V0qE3Cjk5gjLldRNv7I9sjwQ==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
"integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
"cpu": [
"arm64"
],
@@ -3341,9 +3341,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.1.tgz",
"integrity": "sha512-tEKbJydV3BdIxpAx8aGHW6VDg1xW4LlQuRD/QeFZdZNTreHJpMbJEcdvAcI+Hg6vgQpVpaoEldR9W4F6dYSLqQ==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
"integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
"cpu": [
"riscv64"
],
@@ -3358,9 +3358,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.1.tgz",
"integrity": "sha512-mg5msXHagtHpyCVWgI01M26JeSrgE/otWyGdYcuTwyRYZYEJRTbcNt7hscOkdNlPBe7isScW7PVKbxmAjJJl4g==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
"integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
"cpu": [
"x64"
],
@@ -3375,9 +3375,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.1.tgz",
"integrity": "sha512-lFZEXkpDreUe3zKilvnMsrnKP9gwQudaEjDnOz/GMzbzNceIuPfFZz0cR/ky1Aoq4eSvZonPKHhROq4owz4fzg==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
"integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
"cpu": [
"x64"
],
@@ -3392,9 +3392,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.1.tgz",
"integrity": "sha512-ejc5RAp/Lm1Aj0EQHaT+Wdt5PHfdgQV5hIDV00MV6HNbIb5W4ZUFxMDaRkAg65gl9MvY2fH396riePW3RoKXDw==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
"integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
"cpu": [
"arm64"
],
@@ -3409,9 +3409,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.1.tgz",
"integrity": "sha512-fSATtJDc0fNjVB6ystyi8NbwhNFk8i8E05h6KrsC8Fio5eaJIJvPCbC9pdrPl6kkxN1X7fj25ErBbgfqgcK8Fg==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
"integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
"cpu": [
"ia32"
],
@@ -3426,9 +3426,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.1.tgz",
"integrity": "sha512-/JHlOzpUDhjBOO9w167bcYxfJbcMQv7ykS/Y07xjtcga8np0rzUzVGWYmLMH7orKcDMC7wjhheEW1x8cbGma/Q==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
"integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
"cpu": [
"x64"
],
@@ -3443,63 +3443,63 @@
}
},
"node_modules/@tauri-apps/plugin-clipboard-manager": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.0.tgz",
"integrity": "sha512-81NOBA2P+OTY8RLkBwyl9ZR/0CeggLub4F6zxcxUIfFOAqtky7J61+K/MkH2SC1FMxNBxrX0swDuKvkjkHadlA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz",
"integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.6.0"
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.4.0.tgz",
"integrity": "sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.4.2.tgz",
"integrity": "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.2.tgz",
"integrity": "sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig==",
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.4.tgz",
"integrity": "sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-log": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.7.0.tgz",
"integrity": "sha512-81XQ2f93x4vmIB5OY0XlYAxy60cHdYLs0Ki8Qp38tNATRiuBit+Orh3frpY3qfYQnqEvYVyRub7YRJWlmW2RRA==",
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.7.1.tgz",
"integrity": "sha512-jdb+o0wxQc8PjnLktgGpOs9Dh1YupaOGDXzO+Y8peA1UZ1ep3eXv4E1oiJ7nIQVN0XUFDDhnn3aBszl8ijhR+A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.0.tgz",
"integrity": "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz",
"integrity": "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-os": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.1.tgz",
"integrity": "sha512-ty5V8XDUIFbSnrk3zsFoP3kzN+vAufYzalJSlmrVhQTImIZa1aL1a03bOaP2vuBvfR+WDRC6NgV2xBl8G07d+w==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz",
"integrity": "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.1.tgz",
"integrity": "sha512-jjs2WGDO/9z2pjNlydY/F5yYhNsscv99K5lCmU5uKjsVvQ3dRlDhhtVYoa4OLDmktLtQvgvbQjCFibMl6tgGfw==",
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.3.tgz",
"integrity": "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
@@ -18687,14 +18687,14 @@
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-log": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-os": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.1",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-log": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.3",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"cm6-graphql": "^0.2.1",

View File

@@ -88,7 +88,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.7",
"@tauri-apps/cli": "^2.9.1",
"@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.3.4",
"nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5",

View File

@@ -71,10 +71,10 @@ export async function getOrRefreshAccessToken(
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
if (resp.status === 401) {
// Bad refresh token, so we'll force it to fetch a fresh access token by deleting
// and returning null;
console.log('[oauth2] Unauthorized refresh_token request');
if (resp.status >= 400 && resp.status < 500) {
// Client errors (4xx) indicate the refresh token is invalid, expired, or revoked
// Delete the token and return null to trigger a fresh authorization flow
console.log('[oauth2] Refresh token request failed with client error, deleting token');
await deleteToken(ctx, tokenArgs);
return null;
}

View File

@@ -55,6 +55,34 @@ export const plugin: PluginDefinition = {
},
};
/**
* Decodes escape sequences in shell $'...' strings
* Handles Unicode escape sequences (\uXXXX) and common escape codes
*/
function decodeShellString(str: string): string {
return str
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
/**
* Checks if a string might contain escape sequences that need decoding
* If so, decodes them; otherwise returns the string as-is
*/
function maybeDecodeEscapeSequences(str: string): string {
// Check if the string contains escape sequences that shell-quote might not handle
if (str.includes('\\u') || str.includes('\\x')) {
return decodeShellString(str);
}
return str;
}
export function convertCurl(rawData: string) {
if (!rawData.match(/^\s*curl /)) {
return null;
@@ -86,9 +114,11 @@ export function convertCurl(rawData: string) {
for (const parseEntry of normalizedParseEntries) {
if (typeof parseEntry === 'string') {
if (parseEntry.startsWith('$')) {
currentCommand.push(parseEntry.slice(1));
// Handle $'...' strings from shell-quote - decode escape sequences
currentCommand.push(decodeShellString(parseEntry.slice(1)));
} else {
currentCommand.push(parseEntry);
// Decode escape sequences that shell-quote might not handle
currentCommand.push(maybeDecodeEscapeSequences(parseEntry));
}
continue;
}
@@ -108,7 +138,7 @@ export function convertCurl(rawData: string) {
if (op?.startsWith('$')) {
// Handle the case where literal like -H $'Header: \'Some Quoted Thing\''
const str = op.slice(2, op.length - 1).replace(/\\'/g, "'");
const str = decodeShellString(op.slice(2, op.length - 1));
currentCommand.push(str);
continue;

View File

@@ -391,6 +391,56 @@ describe('importer-curl', () => {
},
});
});
test('Imports data with Unicode escape sequences', () => {
expect(
convertCurl(
`curl 'https://yaak.app' -H 'Content-Type: application/json' --data-raw $'{"query":"SearchQueryInput\\u0021"}' -X POST`,
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
body: { text: '{"query":"SearchQueryInput!"}' },
}),
],
},
});
});
test('Imports data with multiple escape sequences', () => {
expect(
convertCurl(
`curl 'https://yaak.app' --data-raw $'Line1\\nLine2\\tTab\\u0021Exclamation' -X POST`,
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [{ name: 'Line1\nLine2\tTab!Exclamation', value: '', enabled: true }],
},
headers: [
{
enabled: true,
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
},
],
}),
],
},
});
});
});
const idCount: Partial<Record<string, number>> = {};

View File

@@ -1,26 +1,86 @@
import crypto from 'node:crypto';
import type { Client } from '@1password/sdk';
import { createClient } from '@1password/sdk';
import type { PluginDefinition } from '@yaakapp/api';
import { createClient, DesktopAuth } from '@1password/sdk';
import type { JsonPrimitive, PluginDefinition } from '@yaakapp/api';
import type { CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
const _clients: Record<string, Client> = {};
async function op(args: CallTemplateFunctionArgs): Promise<Client | null> {
const token = args.values.token;
if (typeof token !== 'string') return null;
async function op(args: CallTemplateFunctionArgs): Promise<{ client?: Client; error?: unknown }> {
let authMethod: string | DesktopAuth | null = null;
let hash: string | null = null;
switch (args.values.authMethod) {
case 'desktop': {
const account = args.values.token;
if (typeof account !== 'string' || !account) return { error: 'Missing account name' };
hash = crypto.createHash('sha256').update(`desktop:${account}`).digest('hex');
authMethod = new DesktopAuth(account);
break;
}
case 'token': {
const token = args.values.token;
if (typeof token !== 'string' || !token) return { error: 'Missing service token' };
hash = crypto.createHash('sha256').update(`token:${token}`).digest('hex');
authMethod = token;
break;
}
}
if (hash == null || authMethod == null) return { error: 'Invalid authentication method' };
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
try {
_clients[tokenHash] ??= await createClient({
auth: token,
_clients[hash] ??= await createClient({
auth: authMethod,
integrationName: 'Yaak 1Password Plugin',
integrationVersion: 'v1.0.0',
});
} catch {
return null;
} catch (e) {
return { error: e };
}
return _clients[tokenHash];
return { client: _clients[hash] };
}
async function getValue(
args: CallTemplateFunctionArgs,
vaultId?: JsonPrimitive,
itemId?: JsonPrimitive,
fieldId?: JsonPrimitive,
): Promise<{ value?: string; error?: unknown }> {
const { client, error } = await op(args);
if (!client) return { error };
if (vaultId && typeof vaultId === 'string') {
try {
await client.vaults.getOverview(vaultId);
} catch {
return { error: `Vault ${vaultId} not found` };
}
} else {
return { error: 'No vault specified' };
}
if (itemId && typeof itemId === 'string') {
try {
const item = await client.items.get(vaultId, itemId);
if (fieldId && typeof fieldId === 'string') {
const field = item.fields.find((f) => f.id === fieldId);
if (field) {
return { value: field.value };
} else {
return { error: `Field ${fieldId} not found in item ${itemId} in vault ${vaultId}` };
}
}
} catch {
return { error: `Item ${itemId} not found in vault ${vaultId}` };
}
} else {
return { error: 'No item specified' };
}
return {};
}
export const plugin: PluginDefinition = {
@@ -31,14 +91,50 @@ export const plugin: PluginDefinition = {
previewArgs: ['field'],
args: [
{
name: 'token',
type: 'text',
label: '1Password Service Account Token',
description:
'Token can be generated from the 1Password website by visiting Developer > Service Accounts',
// biome-ignore lint/suspicious/noTemplateCurlyInString: Yaak template syntax
defaultValue: '${[1PASSWORD_TOKEN]}',
password: true,
type: 'h_stack',
inputs: [
{
name: 'authMethod',
type: 'select',
label: 'Authentication Method',
defaultValue: 'token',
options: [
{
label: 'Service Account',
value: 'token',
},
{
label: 'Desktop App',
value: 'desktop',
},
],
},
{
name: 'token',
type: 'text',
// biome-ignore lint/suspicious/noTemplateCurlyInString: Yaak template syntax
defaultValue: '${[1PASSWORD_TOKEN]}',
dynamic(_ctx, args) {
switch (args.values.authMethod) {
case 'desktop':
return {
label: 'Account Name',
description:
'Account name can be taken from the sidebar of the 1Password App. Make sure you\'re on the BETA version of the 1Password app and have "Integrate with other apps" enabled in Settings > Developer.',
};
case 'token':
return {
label: 'Token',
description:
'Token can be generated from the 1Password website by visiting Developer > Service Accounts',
password: true,
};
}
return { hidden: true };
},
},
],
},
{
name: 'vault',
@@ -46,7 +142,7 @@ export const plugin: PluginDefinition = {
type: 'select',
options: [],
async dynamic(_ctx, args) {
const client = await op(args);
const { client } = await op(args);
if (client == null) return { hidden: true };
// Fetches a secret.
const vaults = await client.vaults.list({ decryptDetails: true });
@@ -64,18 +160,23 @@ export const plugin: PluginDefinition = {
type: 'select',
options: [],
async dynamic(_ctx, args) {
const client = await op(args);
const { client } = await op(args);
if (client == null) return { hidden: true };
const vaultId = args.values.vault;
if (typeof vaultId !== 'string') return { hidden: true };
const items = await client.items.list(vaultId);
return {
options: items.map((item) => ({
label: `${item.title} ${item.category}`,
value: item.id,
})),
};
try {
const items = await client.items.list(vaultId);
return {
options: items.map((item) => ({
label: `${item.title} ${item.category}`,
value: item.id,
})),
};
} catch {
// Hide as we can't list the items for this vault
return { hidden: true };
}
},
},
{
@@ -84,7 +185,7 @@ export const plugin: PluginDefinition = {
type: 'select',
options: [],
async dynamic(_ctx, args) {
const client = await op(args);
const { client } = await op(args);
if (client == null) return { hidden: true };
const vaultId = args.values.vault;
const itemId = args.values.item;
@@ -92,34 +193,28 @@ export const plugin: PluginDefinition = {
return { hidden: true };
}
const item = await client.items.get(vaultId, itemId);
return {
options: item.fields.map((field) => ({ label: field.title, value: field.id })),
};
try {
const item = await client.items.get(vaultId, itemId);
return {
options: item.fields.map((field) => ({ label: field.title, value: field.id })),
};
} catch {
// Hide as we can't find the item within this vault
return { hidden: true };
}
},
},
],
async onRender(_ctx, args) {
const client = await op(args);
if (client == null) throw new Error('Invalid token');
const vaultId = args.values.vault;
const itemId = args.values.item;
const fieldId = args.values.field;
if (
typeof vaultId !== 'string' ||
typeof itemId !== 'string' ||
typeof fieldId !== 'string'
) {
return null;
const { value, error } = await getValue(args, vaultId, itemId, fieldId);
if (error) {
throw error;
}
const item = await client.items.get(vaultId, itemId);
const field = item.fields.find((f) => f.id === fieldId);
if (field == null) {
throw new Error(`Field not found: ${fieldId}`);
}
return field.value ?? '';
return value ?? '';
},
},
],

166
src-tauri/Cargo.lock generated
View File

@@ -473,6 +473,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -700,6 +709,15 @@ dependencies = [
"toml 0.8.23",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.26"
@@ -1230,6 +1248,15 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "des"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
dependencies = [
"cipher",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -2623,6 +2650,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -3009,9 +3037,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
@@ -3739,6 +3767,23 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "p12"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4873306de53fe82e7e484df31e1e947d61514b6ea2ed6cd7b45d63006fd9224"
dependencies = [
"cbc",
"cipher",
"des",
"getrandom 0.2.16",
"hmac",
"lazy_static",
"rc2",
"sha1",
"yasna",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -4489,6 +4534,15 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rc2"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd"
dependencies = [
"cipher",
]
[[package]]
name = "redox_syscall"
version = "0.5.12"
@@ -4787,6 +4841,15 @@ dependencies = [
"security-framework 3.5.1",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
@@ -5620,9 +5683,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.9.2"
version = "2.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bceb52453e507c505b330afe3398510e87f428ea42b6e76ecb6bd63b15965b5"
checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033"
dependencies = [
"anyhow",
"bytes",
@@ -5672,9 +5735,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.5.1"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a924b6c50fe83193f0f8b14072afa7c25b7a72752a2a73d9549b463f5fe91a38"
checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
dependencies = [
"anyhow",
"cargo_toml",
@@ -5694,9 +5757,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.5.0"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c1fe64c74cc40f90848281a90058a6db931eb400b60205840e09801ee30f190"
checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -5721,9 +5784,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.5.0"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "260c5d2eb036b76206b9fca20b7be3614cfd21046c5396f7959e0e64a4b07f2f"
checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -5735,9 +5798,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.5.1"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d"
checksum = "0e1d0a4860b7ff570c891e1d2a586bf1ede205ff858fbc305e0b5ae5d14c1377"
dependencies = [
"anyhow",
"glob",
@@ -5752,9 +5815,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.0"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adddd9e9275b20e77af3061d100a25a884cced3c4c9ef680bd94dd0f7e26c1ca"
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
dependencies = [
"arboard",
"log",
@@ -5767,9 +5830,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.3"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd67112fb1131834c2a7398ffcba520dbbf62c17de3b10329acd1a3554b1a9bb"
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
dependencies = [
"dunce",
"plist",
@@ -5828,9 +5891,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-log"
version = "2.7.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c1438bc7662acd16d508c919b3c087efd63669a4c75625dff829b1c75975ec"
checksum = "d5709c792b8630290b5d9811a1f8fe983dd925fc87c7fc7f4923616458cd00b6"
dependencies = [
"android_logger",
"byte-unit",
@@ -5850,9 +5913,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-opener"
version = "2.5.0"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786156aa8e89e03d271fbd3fe642207da8e65f3c961baa9e2930f332bf80a1f5"
checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b"
dependencies = [
"dunce",
"glob",
@@ -5872,9 +5935,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-os"
version = "2.3.1"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba"
checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
dependencies = [
"gethostname 1.0.2",
"log",
@@ -5911,9 +5974,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.4"
version = "2.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb9cac815bf11c4a80fb498666bcdad66d65b89e3ae24669e47806febb76389c"
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
dependencies = [
"serde",
"serde_json",
@@ -5959,9 +6022,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5f6fe3291bfa609c7e0b0ee3bedac294d94c7018934086ce782c1d0f2a468e"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.9.1",
"log",
@@ -5974,9 +6037,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926"
checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892"
dependencies = [
"cookie",
"dpi",
@@ -5999,9 +6062,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.9.1"
version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93"
checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065"
dependencies = [
"gtk",
"http",
@@ -6026,9 +6089,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.8.0"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490"
dependencies = [
"anyhow",
"brotli",
@@ -6810,9 +6873,9 @@ dependencies = [
[[package]]
name = "value-bag"
version = "1.11.1"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "vcpkg"
@@ -7114,9 +7177,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.0"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a83f7e1a9f8712695c03eabe9ed3fbca0feff0152f33f12593e5a6303cb1a4"
checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b"
dependencies = [
"rustls-pki-types",
]
@@ -7855,6 +7918,7 @@ dependencies = [
"yaak-sse",
"yaak-sync",
"yaak-templates",
"yaak-tls",
"yaak-ws",
]
@@ -7933,12 +7997,13 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin-shell",
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tonic",
"tonic-reflection",
"uuid",
"yaak-http",
"yaak-tls",
]
[[package]]
@@ -7950,8 +8015,6 @@ dependencies = [
"regex",
"reqwest",
"reqwest_cookie_store",
"rustls",
"rustls-platform-verifier",
"serde",
"tauri",
"thiserror 2.0.17",
@@ -7959,6 +8022,7 @@ dependencies = [
"tower-service",
"urlencoding",
"yaak-models",
"yaak-tls",
]
[[package]]
@@ -8093,13 +8157,28 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "yaak-tls"
version = "0.1.0"
dependencies = [
"log",
"p12",
"rustls",
"rustls-pemfile",
"rustls-platform-verifier",
"serde",
"thiserror 2.0.17",
"url",
"yaak-models",
]
[[package]]
name = "yaak-ws"
version = "0.1.0"
dependencies = [
"futures-util",
"log",
"md5 0.7.0",
"md5 0.8.0",
"reqwest_cookie_store",
"serde",
"serde_json",
@@ -8112,8 +8191,15 @@ dependencies = [
"yaak-models",
"yaak-plugins",
"yaak-templates",
"yaak-tls",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
[[package]]
name = "yoke"
version = "0.8.0"

View File

@@ -12,6 +12,7 @@ members = [
"yaak-sse",
"yaak-sync",
"yaak-templates",
"yaak-tls",
"yaak-ws",
]
@@ -28,7 +29,9 @@ name = "tauri_app_lib"
crate-type = ["staticlib", "cdylib", "lib"]
[profile.release]
strip = true # Automatically strip symbols from the binary.
# Currently disabled due to:
# Warn Failed to add bundler type to the binary: __TAURI_BUNDLE_TYPE variable not found in binary. Make sure tauri crate and tauri-cli are up to date and that symbol stripping is disabled (https://doc.rust-lang.org/cargo/reference/profiles.html#strip). Updater plugin may not be able to update this package. This shouldn't normally happen, please report it to https://github.com/tauri-apps/tauri/issues
strip = false
[features]
cargo-clippy = []
@@ -37,7 +40,7 @@ updater = []
license = ["yaak-license"]
[build-dependencies]
tauri-build = { version = "2.5.0", features = [] }
tauri-build = { version = "2.5.3", features = [] }
[target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
@@ -57,17 +60,17 @@ reqwest_cookie_store = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2.3.0"
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-clipboard-manager = "2.3.2"
tauri-plugin-deep-link = "2.4.5"
tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = { version = "2.7.0", features = ["colored"] }
tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.1"
tauri-plugin-fs = "2.4.4"
tauri-plugin-log = { version = "2.7.1", features = ["colored"] }
tauri-plugin-opener = "2.5.2"
tauri-plugin-os = "2.3.2"
tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = { version = "2.3.4", features = ["deep-link"] }
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
tauri-plugin-updater = "2.9.0"
tauri-plugin-window-state = "2.4.0"
tauri-plugin-window-state = "2.4.1"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-stream = "0.1.17"
@@ -86,6 +89,7 @@ yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
yaak-ws = { path = "yaak-ws" }
[workspace.dependencies]
@@ -99,9 +103,9 @@ rustls-platform-verifier = "0.6.2"
serde = "1.0.228"
serde_json = "1.0.145"
sha2 = "0.10.9"
log = "0.4.28"
tauri = "2.9.2"
tauri-plugin = "2.5.1"
log = "0.4.29"
tauri = "2.9.5"
tauri-plugin = "2.5.2"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-shell = "2.3.3"
thiserror = "2.0.17"
@@ -116,3 +120,4 @@ yaak-plugins = { path = "yaak-plugins" }
yaak-sse = { path = "yaak-sse" }
yaak-sync = { path = "yaak-sync" }
yaak-templates = { path = "yaak-templates" }
yaak-tls = { path = "yaak-tls" }

View File

@@ -6,6 +6,10 @@
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Allow loading 1Password's dylib (signed with different Team ID) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
<!-- <key>com.apple.security.app-sandbox</key> <true/>-->
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->

View File

@@ -37,6 +37,7 @@ use yaak_plugins::events::{
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
pub async fn send_http_request<R: Runtime>(
window: &WebviewWindow<R>,
@@ -151,6 +152,8 @@ pub async fn send_http_request_with_context<R: Runtime>(
}
};
let client_certificate = find_client_certificate(&url_string, &settings.client_certificates);
// Add cookie store if specified
let maybe_cookie_manager = match cookie_jar.clone() {
Some(CookieJar { id, .. }) => {
@@ -178,22 +181,19 @@ pub async fn send_http_request_with_context<R: Runtime>(
};
let client = connection_manager
.get_client(
&plugin_context.id,
&HttpConnectionOptions {
follow_redirects: workspace.setting_follow_redirects,
validate_certificates: workspace.setting_validate_certificates,
proxy: proxy_setting,
cookie_provider: maybe_cookie_manager.as_ref().map(|(p, _)| Arc::clone(&p)),
timeout: if workspace.setting_request_timeout > 0 {
Some(Duration::from_millis(
workspace.setting_request_timeout.unsigned_abs() as u64
))
} else {
None
},
.get_client(&HttpConnectionOptions {
id: plugin_context.id.clone(),
follow_redirects: workspace.setting_follow_redirects,
validate_certificates: workspace.setting_validate_certificates,
proxy: proxy_setting,
cookie_provider: maybe_cookie_manager.as_ref().map(|(p, _)| Arc::clone(&p)),
client_certificate,
timeout: if workspace.setting_request_timeout > 0 {
Some(Duration::from_millis(workspace.setting_request_timeout.unsigned_abs() as u64))
} else {
None
},
)
})
.await?;
// Render query parameters

View File

@@ -53,6 +53,7 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_sse::sse::ServerSentEvent;
use yaak_templates::format_json::format_json;
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
use yaak_tls::find_client_certificate;
mod commands;
mod encoding;
@@ -156,6 +157,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
request_id: &str,
environment_id: Option<&str>,
proto_files: Vec<String>,
skip_cache: Option<bool>,
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
@@ -186,6 +188,9 @@ async fn cmd_grpc_reflect<R: Runtime>(
let uri = safe_uri(&req.url);
let metadata = build_metadata(&window, &req, &auth_context_id).await?;
let settings = window.db().get_settings();
let client_certificate =
find_client_certificate(req.url.as_str(), &settings.client_certificates);
Ok(grpc_handle
.lock()
@@ -196,6 +201,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
workspace.setting_validate_certificates,
client_certificate,
skip_cache.unwrap_or(false),
)
.await
.map_err(|e| GenericError(e.to_string()))?)
@@ -235,6 +242,10 @@ async fn cmd_grpc_go<R: Runtime>(
let metadata = build_metadata(&window, &request, &auth_context_id).await?;
// Find matching client certificate for this URL
let settings = app_handle.db().get_settings();
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
let conn = app_handle.db().upsert_grpc_connection(
&GrpcConnection {
workspace_id: request.workspace_id.clone(),
@@ -283,6 +294,7 @@ async fn cmd_grpc_go<R: Runtime>(
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
workspace.setting_validate_certificates,
client_cert.clone(),
)
.await;
@@ -292,7 +304,7 @@ async fn cmd_grpc_go<R: Runtime>(
app_handle.db().upsert_grpc_connection(
&GrpcConnection {
elapsed: start.elapsed().as_millis() as i32,
error: Some(err.clone()),
error: Some(err.to_string()),
state: GrpcConnectionState::Closed,
..conn.clone()
},
@@ -423,7 +435,9 @@ async fn cmd_grpc_go<R: Runtime>(
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
(true, true) => (
Some(
connection.streaming(&service, &method, in_msg_stream, &metadata).await,
connection
.streaming(&service, &method, in_msg_stream, &metadata, client_cert)
.await,
),
None,
),
@@ -431,7 +445,13 @@ async fn cmd_grpc_go<R: Runtime>(
None,
Some(
connection
.client_streaming(&service, &method, in_msg_stream, &metadata)
.client_streaming(
&service,
&method,
in_msg_stream,
&metadata,
client_cert,
)
.await,
),
),
@@ -439,9 +459,12 @@ async fn cmd_grpc_go<R: Runtime>(
Some(connection.server_streaming(&service, &method, &msg, &metadata).await),
None,
),
(false, false) => {
(None, Some(connection.unary(&service, &method, &msg, &metadata).await))
}
(false, false) => (
None,
Some(
connection.unary(&service, &method, &msg, &metadata, client_cert).await,
),
),
};
if !method_desc.is_client_streaming() {
@@ -501,7 +524,7 @@ async fn cmd_grpc_go<R: Runtime>(
)
.unwrap();
}
Some(Err(e)) => {
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
app_handle
.db()
.upsert_grpc_event(
@@ -526,6 +549,21 @@ async fn cmd_grpc_go<R: Runtime>(
)
.unwrap();
}
Some(Err(e)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window(&window),
)
.unwrap();
}
None => {
// Server streaming doesn't return the initial message
}
@@ -552,7 +590,7 @@ async fn cmd_grpc_go<R: Runtime>(
.unwrap();
stream.into_inner()
}
Some(Err(e)) => {
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
warn!("GRPC stream error {e:?}");
app_handle
.db()
@@ -579,6 +617,22 @@ async fn cmd_grpc_go<R: Runtime>(
.unwrap();
return;
}
Some(Err(e)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window(&window),
)
.unwrap();
return;
}
None => return,
};

View File

@@ -85,13 +85,18 @@ impl YaakNotifier {
let license_check = {
use yaak_license::{LicenseCheckStatus, check_license};
match check_license(window).await {
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(),
Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(),
Ok(LicenseCheckStatus::InvalidLicense) => "invalid_license".to_string(),
Ok(LicenseCheckStatus::Trialing { .. }) => "trialing".to_string(),
Err(_) => "unknown".to_string(),
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal",
Ok(LicenseCheckStatus::Active { .. }) => "commercial",
Ok(LicenseCheckStatus::PastDue { .. }) => "past_due",
Ok(LicenseCheckStatus::Inactive { .. }) => "invalid_license",
Ok(LicenseCheckStatus::Trialing { .. }) => "trialing",
Ok(LicenseCheckStatus::Expired { .. }) => "expired",
Ok(LicenseCheckStatus::Error { .. }) => "error",
Err(_) => "unknown",
}
.to_string()
};
#[cfg(not(feature = "license"))]
let license_check = "disabled".to_string();

View File

@@ -24,4 +24,5 @@ tokio-stream = "0.1.14"
tonic = { version = "0.12.3", default-features = false, features = ["transport"] }
tonic-reflection = "0.12.3"
uuid = { version = "1.7.0", features = ["v4"] }
yaak-http = { workspace = true }
yaak-tls = { workspace = true }
thiserror = "2.0.17"

View File

@@ -1,3 +1,5 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::manager::decorate_req;
use crate::transport::get_transport;
use async_recursion::async_recursion;
@@ -18,6 +20,7 @@ use tonic_reflection::pb::v1::{
};
use tonic_reflection::pb::v1::{ExtensionRequest, FileDescriptorResponse};
use tonic_reflection::pb::{v1, v1alpha};
use yaak_tls::ClientCertificateConfig;
pub struct AutoReflectionClient<T = Client<HttpsConnector<HttpConnector>, BoxBody>> {
use_v1alpha: bool,
@@ -26,20 +29,24 @@ pub struct AutoReflectionClient<T = Client<HttpsConnector<HttpConnector>, BoxBod
}
impl AutoReflectionClient {
pub fn new(uri: &Uri, validate_certificates: bool) -> Self {
pub fn new(
uri: &Uri,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
) -> Result<Self> {
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates),
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
);
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates),
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
);
AutoReflectionClient {
Ok(AutoReflectionClient {
use_v1alpha: false,
client_v1,
client_v1alpha,
}
})
}
#[async_recursion]
@@ -47,36 +54,40 @@ impl AutoReflectionClient {
&mut self,
message: MessageRequest,
metadata: &BTreeMap<String, String>,
) -> Result<MessageResponse, String> {
) -> Result<MessageResponse> {
let reflection_request = ServerReflectionRequest {
host: "".into(), // Doesn't matter
message_request: Some(message.clone()),
};
if self.use_v1alpha {
let mut request = Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));
decorate_req(metadata, &mut request).map_err(|e| e.to_string())?;
let mut request =
Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));
decorate_req(metadata, &mut request)?;
self.client_v1alpha
.server_reflection_info(request)
.await
.map_err(|e| match e.code() {
tonic::Code::Unavailable => "Failed to connect to endpoint".to_string(),
tonic::Code::Unauthenticated => "Authentication failed".to_string(),
tonic::Code::DeadlineExceeded => "Deadline exceeded".to_string(),
_ => e.to_string(),
tonic::Code::Unavailable => {
GenericError("Failed to connect to endpoint".to_string())
}
tonic::Code::Unauthenticated => {
GenericError("Authentication failed".to_string())
}
tonic::Code::DeadlineExceeded => GenericError("Deadline exceeded".to_string()),
_ => GenericError(e.to_string()),
})?
.into_inner()
.next()
.await
.expect("steamed response")
.map_err(|e| e.to_string())?
.ok_or(GenericError("Missing reflection message".to_string()))??
.message_response
.ok_or("No reflection response".to_string())
.ok_or(GenericError("No reflection response".to_string()))
.map(|resp| to_v1_msg_response(resp))
} else {
let mut request = Request::new(tokio_stream::once(reflection_request));
decorate_req(metadata, &mut request).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut request)?;
let resp = self.client_v1.server_reflection_info(request).await;
match resp {
@@ -92,18 +103,19 @@ impl AutoReflectionClient {
},
}
.map_err(|e| match e.code() {
tonic::Code::Unavailable => "Failed to connect to endpoint".to_string(),
tonic::Code::Unauthenticated => "Authentication failed".to_string(),
tonic::Code::DeadlineExceeded => "Deadline exceeded".to_string(),
_ => e.to_string(),
tonic::Code::Unavailable => {
GenericError("Failed to connect to endpoint".to_string())
}
tonic::Code::Unauthenticated => GenericError("Authentication failed".to_string()),
tonic::Code::DeadlineExceeded => GenericError("Deadline exceeded".to_string()),
_ => GenericError(e.to_string()),
})?
.into_inner()
.next()
.await
.expect("steamed response")
.map_err(|e| e.to_string())?
.ok_or(GenericError("Missing reflection message".to_string()))??
.message_response
.ok_or("No reflection response".to_string())
.ok_or(GenericError("No reflection response".to_string()))
}
}
}

View File

@@ -0,0 +1,51 @@
use crate::manager::GrpcStreamError;
use serde::{Serialize, Serializer};
use serde_json::Error as SerdeJsonError;
use std::io;
use prost::DecodeError;
use thiserror::Error;
use tonic::Status;
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
TlsError(#[from] yaak_tls::error::Error),
#[error(transparent)]
TonicError(#[from] Status),
#[error("Prost reflect error: {0:?}")]
ProstReflectError(#[from] prost_reflect::DescriptorError),
#[error(transparent)]
DeserializerError(#[from] SerdeJsonError),
#[error(transparent)]
GrpcStreamError(#[from] GrpcStreamError),
#[error(transparent)]
GrpcDecodeError(#[from] DecodeError),
#[error(transparent)]
GrpcInvalidMetadataKeyError(#[from] tonic::metadata::errors::InvalidMetadataKey),
#[error(transparent)]
GrpcInvalidMetadataValueError(#[from] tonic::metadata::errors::InvalidMetadataValue),
#[error(transparent)]
IOError(#[from] io::Error),
#[error("GRPC error: {0}")]
GenericError(String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -9,6 +9,7 @@ pub mod manager;
mod reflection;
mod transport;
mod any;
pub mod error;
pub use tonic::metadata::*;
pub use tonic::Code;

View File

@@ -1,4 +1,6 @@
use crate::codec::DynamicCodec;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::reflection::{
fill_pool_from_files, fill_pool_from_reflection, method_desc_to_path, reflect_types_for_message,
};
@@ -7,11 +9,14 @@ use crate::{MethodDefinition, ServiceDefinition, json_schema};
use hyper_rustls::HttpsConnector;
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use log::warn;
use log::{info, warn};
pub use prost_reflect::DynamicMessage;
use prost_reflect::{DescriptorPool, MethodDescriptor, ServiceDescriptor};
use serde_json::Deserializer;
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use std::fmt::Display;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
@@ -23,6 +28,7 @@ use tonic::body::BoxBody;
use tonic::metadata::{MetadataKey, MetadataValue};
use tonic::transport::Uri;
use tonic::{IntoRequest, IntoStreamingRequest, Request, Response, Status, Streaming};
use yaak_tls::ClientCertificateConfig;
#[derive(Clone)]
pub struct GrpcConnection {
@@ -33,23 +39,34 @@ pub struct GrpcConnection {
}
#[derive(Default, Debug)]
pub struct StreamError {
pub struct GrpcStreamError {
pub message: String,
pub status: Option<Status>,
}
impl From<String> for StreamError {
impl Error for GrpcStreamError {}
impl Display for GrpcStreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.status {
Some(status) => write!(f, "[{}] {}", status, self.message),
None => write!(f, "{}", self.message),
}
}
}
impl From<String> for GrpcStreamError {
fn from(value: String) -> Self {
StreamError {
GrpcStreamError {
message: value.to_string(),
status: None,
}
}
}
impl From<Status> for StreamError {
impl From<Status> for GrpcStreamError {
fn from(s: Status) -> Self {
StreamError {
GrpcStreamError {
message: s.message().to_string(),
status: Some(s),
}
@@ -57,16 +74,20 @@ impl From<Status> for StreamError {
}
impl GrpcConnection {
pub async fn method(&self, service: &str, method: &str) -> Result<MethodDescriptor, String> {
pub async fn method(&self, service: &str, method: &str) -> Result<MethodDescriptor> {
let service = self.service(service).await?;
let method =
service.methods().find(|m| m.name() == method).ok_or("Failed to find method")?;
let method = service
.methods()
.find(|m| m.name() == method)
.ok_or(GenericError("Failed to find method".to_string()))?;
Ok(method)
}
async fn service(&self, service: &str) -> Result<ServiceDescriptor, String> {
async fn service(&self, service: &str) -> Result<ServiceDescriptor> {
let pool = self.pool.read().await;
let service = pool.get_service_by_name(service).ok_or("Failed to find service")?;
let service = pool
.get_service_by_name(service)
.ok_or(GenericError("Failed to find service".to_string()))?;
Ok(service)
}
@@ -76,26 +97,27 @@ impl GrpcConnection {
method: &str,
message: &str,
metadata: &BTreeMap<String, String>,
) -> Result<Response<DynamicMessage>, StreamError> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> {
if self.use_reflection {
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata).await?;
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)
.await?;
}
let method = &self.method(&service, &method).await?;
let input_message = method.input();
let mut deserializer = Deserializer::from_str(message);
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)
.map_err(|e| e.to_string())?;
deserializer.end().unwrap();
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut req = req_message.into_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut req)?;
let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone());
client.ready().await.unwrap();
client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?;
Ok(client.unary(req, path, codec).await?)
}
@@ -106,7 +128,8 @@ impl GrpcConnection {
method: &str,
stream: ReceiverStream<String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<Streaming<DynamicMessage>>, StreamError> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<Streaming<DynamicMessage>>> {
let method = &self.method(&service, &method).await?;
let mapped_stream = {
let input_message = method.input();
@@ -114,15 +137,19 @@ impl GrpcConnection {
let uri = self.uri.clone();
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
stream.filter_map(move |json| {
let pool = pool.clone();
let uri = uri.clone();
let input_message = input_message.clone();
let md = md.clone();
let use_reflection = use_reflection.clone();
let client_cert = client_cert.clone();
tauri::async_runtime::block_on(async move {
if use_reflection {
if let Err(e) = reflect_types_for_message(pool, &uri, &json, &md).await {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
}
@@ -143,9 +170,9 @@ impl GrpcConnection {
let codec = DynamicCodec::new(method.clone());
let mut req = mapped_stream.into_streaming_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut req)?;
client.ready().await.map_err(|e| e.to_string())?;
client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?;
Ok(client.streaming(req, path, codec).await?)
}
@@ -155,7 +182,8 @@ impl GrpcConnection {
method: &str,
stream: ReceiverStream<String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<DynamicMessage>, StreamError> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> {
let method = &self.method(&service, &method).await?;
let mapped_stream = {
let input_message = method.input();
@@ -163,15 +191,19 @@ impl GrpcConnection {
let uri = self.uri.clone();
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
stream.filter_map(move |json| {
let pool = pool.clone();
let uri = uri.clone();
let input_message = input_message.clone();
let md = md.clone();
let use_reflection = use_reflection.clone();
let client_cert = client_cert.clone();
tauri::async_runtime::block_on(async move {
if use_reflection {
if let Err(e) = reflect_types_for_message(pool, &uri, &json, &md).await {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
}
@@ -192,13 +224,13 @@ impl GrpcConnection {
let codec = DynamicCodec::new(method.clone());
let mut req = mapped_stream.into_streaming_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut req)?;
client.ready().await.unwrap();
client.client_streaming(req, path, codec).await.map_err(|e| StreamError {
client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?;
Ok(client.client_streaming(req, path, codec).await.map_err(|e| GrpcStreamError {
message: e.message().to_string(),
status: Some(e),
})
})?)
}
pub async fn server_streaming(
@@ -207,23 +239,22 @@ impl GrpcConnection {
method: &str,
message: &str,
metadata: &BTreeMap<String, String>,
) -> Result<Response<Streaming<DynamicMessage>>, StreamError> {
) -> Result<Response<Streaming<DynamicMessage>>> {
let method = &self.method(&service, &method).await?;
let input_message = method.input();
let mut deserializer = Deserializer::from_str(message);
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)
.map_err(|e| e.to_string())?;
deserializer.end().unwrap();
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut req = req_message.into_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut req)?;
let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone());
client.ready().await.map_err(|e| e.to_string())?;
client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?;
Ok(client.server_streaming(req, path, codec).await?)
}
}
@@ -244,6 +275,12 @@ impl GrpcHandle {
}
impl GrpcHandle {
/// Remove cached descriptor pool for the given key, if present.
pub fn invalidate_pool(&mut self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) {
let key = make_pool_key(id, uri, proto_files);
self.pools.remove(&key);
}
pub async fn reflect(
&mut self,
id: &str,
@@ -251,16 +288,24 @@ impl GrpcHandle {
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
) -> Result<bool, String> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<bool> {
let server_reflection = proto_files.is_empty();
let key = make_pool_key(id, uri, proto_files);
// If we already have a pool for this key, reuse it and avoid re-reflection
if self.pools.contains_key(&key) {
return Ok(server_reflection);
}
let pool = if server_reflection {
let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(&full_uri, metadata, validate_certificates).await
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await
} else {
fill_pool_from_files(&self.app_handle, proto_files).await
}?;
self.pools.insert(make_pool_key(id, uri, proto_files), pool.clone());
self.pools.insert(key, pool.clone());
Ok(server_reflection)
}
@@ -271,11 +316,19 @@ impl GrpcHandle {
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
) -> Result<Vec<ServiceDefinition>, String> {
// Ensure reflection is up-to-date
self.reflect(id, uri, proto_files, metadata, validate_certificates).await?;
client_cert: Option<ClientCertificateConfig>,
skip_cache: bool,
) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing
if skip_cache || self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
.await?;
}
let pool = self.get_pool(id, uri, proto_files).ok_or("Failed to get pool".to_string())?;
let pool = self
.get_pool(id, uri, proto_files)
.ok_or(GenericError("Failed to get pool".to_string()))?;
Ok(self.services_from_pool(&pool))
}
@@ -296,7 +349,7 @@ impl GrpcHandle {
&pool,
input_message,
))
.unwrap(),
.expect("Failed to serialize JSON schema"),
})
}
def
@@ -311,12 +364,26 @@ impl GrpcHandle {
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
) -> Result<GrpcConnection, String> {
let use_reflection =
self.reflect(id, uri, proto_files, metadata, validate_certificates).await?;
let pool = self.get_pool(id, uri, proto_files).ok_or("Failed to get pool")?.clone();
client_cert: Option<ClientCertificateConfig>,
) -> Result<GrpcConnection> {
let use_reflection = proto_files.is_empty();
if self.get_pool(id, uri, proto_files).is_none() {
self.reflect(
id,
uri,
proto_files,
metadata,
validate_certificates,
client_cert.clone(),
)
.await?;
}
let pool = self
.get_pool(id, uri, proto_files)
.ok_or(GenericError("Failed to get pool".to_string()))?
.clone();
let uri = uri_from_str(uri)?;
let conn = get_transport(validate_certificates);
let conn = get_transport(validate_certificates, client_cert.clone())?;
Ok(GrpcConnection {
pool: Arc::new(RwLock::new(pool)),
use_reflection,
@@ -333,22 +400,20 @@ impl GrpcHandle {
pub(crate) fn decorate_req<T>(
metadata: &BTreeMap<String, String>,
req: &mut Request<T>,
) -> Result<(), String> {
) -> Result<()> {
for (k, v) in metadata {
req.metadata_mut().insert(
MetadataKey::from_str(k.as_str()).map_err(|e| e.to_string())?,
MetadataValue::from_str(v.as_str()).map_err(|e| e.to_string())?,
);
req.metadata_mut()
.insert(MetadataKey::from_str(k.as_str())?, MetadataValue::from_str(v.as_str())?);
}
Ok(())
}
fn uri_from_str(uri_str: &str) -> Result<Uri, String> {
fn uri_from_str(uri_str: &str) -> Result<Uri> {
match Uri::from_str(uri_str) {
Ok(uri) => Ok(uri),
Err(err) => {
// Uri::from_str basically only returns "invalid format" so we add more context here
Err(format!("Failed to parse URL, {}", err.to_string()))
Err(GenericError(format!("Failed to parse URL, {}", err.to_string())))
}
}
}

View File

@@ -1,5 +1,7 @@
use crate::any::collect_any_types;
use crate::client::AutoReflectionClient;
use crate::error::Error::GenericError;
use crate::error::Result;
use anyhow::anyhow;
use async_recursion::async_recursion;
use log::{debug, info, warn};
@@ -21,11 +23,12 @@ use tonic::codegen::http::uri::PathAndQuery;
use tonic::transport::Uri;
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
use yaak_tls::ClientCertificateConfig;
pub async fn fill_pool_from_files(
app_handle: &AppHandle,
paths: &Vec<PathBuf>,
) -> Result<DescriptorPool, String> {
) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new();
let random_file_name = format!("{}.desc", uuid::Uuid::new_v4());
let desc_path = temp_dir().join(random_file_name);
@@ -103,18 +106,18 @@ pub async fn fill_pool_from_files(
.expect("yaakprotoc failed to run");
if !out.status.success() {
return Err(format!(
return Err(GenericError(format!(
"protoc failed with status {}: {}",
out.status.code().unwrap(),
String::from_utf8_lossy(out.stderr.as_slice())
));
)));
}
let bytes = fs::read(desc_path).await.map_err(|e| e.to_string())?;
let fdp = FileDescriptorSet::decode(bytes.deref()).map_err(|e| e.to_string())?;
pool.add_file_descriptor_set(fdp).map_err(|e| e.to_string())?;
let bytes = fs::read(desc_path).await?;
let fdp = FileDescriptorSet::decode(bytes.deref())?;
pool.add_file_descriptor_set(fdp)?;
fs::remove_file(desc_path).await.map_err(|e| e.to_string())?;
fs::remove_file(desc_path).await?;
Ok(pool)
}
@@ -123,9 +126,10 @@ pub async fn fill_pool_from_reflection(
uri: &Uri,
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
) -> Result<DescriptorPool, String> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new();
let mut client = AutoReflectionClient::new(uri, validate_certificates);
let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?;
for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" {
@@ -144,7 +148,7 @@ pub async fn fill_pool_from_reflection(
async fn list_services(
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) -> Result<Vec<String>, String> {
) -> Result<Vec<String>> {
let response =
client.send_reflection_request(MessageRequest::ListServices("".into()), metadata).await?;
@@ -171,7 +175,7 @@ async fn file_descriptor_set_from_service_name(
{
Ok(resp) => resp,
Err(e) => {
warn!("Error fetching file descriptor for service {}: {}", service_name, e);
warn!("Error fetching file descriptor for service {}: {:?}", service_name, e);
return;
}
};
@@ -195,7 +199,8 @@ pub(crate) async fn reflect_types_for_message(
uri: &Uri,
json: &str,
metadata: &BTreeMap<String, String>,
) -> Result<(), String> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<()> {
// 1. Collect all Any types in the JSON
let mut extra_types = Vec::new();
collect_any_types(json, &mut extra_types);
@@ -204,7 +209,7 @@ pub(crate) async fn reflect_types_for_message(
return Ok(()); // nothing to do
}
let mut client = AutoReflectionClient::new(uri, false);
let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
for extra_type in extra_types {
{
let guard = pool.read().await;
@@ -217,9 +222,9 @@ pub(crate) async fn reflect_types_for_message(
let resp = match client.send_reflection_request(req, metadata).await {
Ok(r) => r,
Err(e) => {
return Err(format!(
"Error sending reflection request for @type \"{extra_type}\": {e}",
));
return Err(GenericError(format!(
"Error sending reflection request for @type \"{extra_type}\": {e:?}",
)));
}
};
let files = match resp {
@@ -286,7 +291,7 @@ async fn file_descriptor_set_by_filename(
panic!("Expected a FileDescriptorResponse variant")
}
Err(e) => {
warn!("Error fetching file descriptor for {}: {}", filename, e);
warn!("Error fetching file descriptor for {}: {:?}", filename, e);
return;
}
};

View File

@@ -1,25 +1,41 @@
use crate::error::Result;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use log::info;
use tonic::body::BoxBody;
use yaak_tls::{get_tls_config, ClientCertificateConfig};
// I think ALPN breaks this because we're specifying http2_only
const WITH_ALPN: bool = false;
pub(crate) fn get_transport(validate_certificates: bool) -> Client<HttpsConnector<HttpConnector>, BoxBody> {
let tls_config = yaak_http::tls::get_config(validate_certificates, WITH_ALPN);
pub(crate) fn get_transport(
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
) -> Result<Client<HttpsConnector<HttpConnector>, BoxBody>> {
let tls_config =
get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
let mut http = HttpConnector::new();
http.enforce_http(false);
let connector =
HttpsConnectorBuilder::new().with_tls_config(tls_config).https_or_http().enable_http2().build();
let connector = HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_http2()
.build();
let client = Client::builder(TokioExecutor::new())
.pool_max_idle_per_host(0)
.http2_only(true)
.build(connector);
client
info!(
"Created gRPC client validate_certs={} client_cert={}",
validate_certificates,
client_cert.is_some()
);
Ok(client)
}

View File

@@ -5,17 +5,16 @@ edition = "2024"
publish = false
[dependencies]
yaak-models = { workspace = true }
hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] }
log = { workspace = true }
regex = "1.11.1"
rustls = { workspace = true, default-features = false, features = ["ring"] }
rustls-platform-verifier = { workspace = true }
urlencoding = "2.1.3"
tauri = { workspace = true }
tokio = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json", "rustls-tls-manual-roots-no-provider", "socks", "http2"] }
reqwest_cookie_store = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true, features = ["derive"] }
hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] }
tauri = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tower-service = "0.3.3"
log = { workspace = true }
urlencoding = "2.1.3"
yaak-models = { workspace = true }
yaak-tls = { workspace = true }

View File

@@ -1,12 +1,12 @@
use crate::dns::LocalhostResolver;
use crate::error::Result;
use crate::tls;
use log::{debug, warn};
use log::{debug, info, warn};
use reqwest::redirect::Policy;
use reqwest::{Client, Proxy};
use reqwest_cookie_store::CookieStoreMutex;
use std::sync::Arc;
use std::time::Duration;
use yaak_tls::{ClientCertificateConfig, get_tls_config};
#[derive(Clone)]
pub struct HttpConnectionProxySettingAuth {
@@ -28,11 +28,13 @@ pub enum HttpConnectionProxySetting {
#[derive(Clone)]
pub struct HttpConnectionOptions {
pub id: String,
pub follow_redirects: bool,
pub validate_certificates: bool,
pub proxy: HttpConnectionProxySetting,
pub cookie_provider: Option<Arc<CookieStoreMutex>>,
pub timeout: Option<Duration>,
pub client_certificate: Option<ClientCertificateConfig>,
}
impl HttpConnectionOptions {
@@ -45,8 +47,10 @@ impl HttpConnectionOptions {
.referer(false)
.tls_info(true);
// Configure TLS
client = client.use_preconfigured_tls(tls::get_config(self.validate_certificates, true));
// Configure TLS with optional client certificate
let config =
get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?;
client = client.use_preconfigured_tls(config);
// Configure DNS resolver
client = client.dns_resolver(LocalhostResolver::new());
@@ -85,6 +89,12 @@ impl HttpConnectionOptions {
client = client.timeout(d);
}
info!(
"Building new HTTP client validate_certificates={} client_cert={}",
self.validate_certificates,
self.client_certificate.is_some()
);
Ok(client.build()?)
}
}

View File

@@ -3,8 +3,11 @@ use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
#[error("Client error: {0:?}")]
Client(#[from] reqwest::Error),
#[error(transparent)]
TlsError(#[from] yaak_tls::error::Error),
}
impl Serialize for Error {

View File

@@ -7,7 +7,6 @@ pub mod dns;
pub mod error;
pub mod manager;
pub mod path_placeholders;
pub mod tls;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-http")

View File

@@ -20,19 +20,19 @@ impl HttpConnectionManager {
}
}
pub async fn get_client(&self, id: &str, opt: &HttpConnectionOptions) -> Result<Client> {
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<Client> {
let mut connections = self.connections.write().await;
let id = opt.id.clone();
// Clean old connections
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
if let Some((c, last_used)) = connections.get_mut(id) {
if let Some((c, last_used)) = connections.get_mut(&id) {
info!("Re-using HTTP client {id}");
*last_used = Instant::now();
return Ok(c.clone());
}
info!("Building new HTTP client {id}");
let c = opt.build_client()?;
connections.insert(id.into(), (c.clone(), Instant::now()));
Ok(c)

View File

@@ -1,81 +0,0 @@
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::ring;
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use rustls_platform_verifier::BuilderVerifierExt;
use std::sync::Arc;
pub fn get_config(validate_certificates: bool, with_alpn: bool) -> ClientConfig {
let arc_crypto_provider = Arc::new(ring::default_provider());
let config_builder = ClientConfig::builder_with_provider(arc_crypto_provider)
.with_safe_default_protocol_versions()
.unwrap();
let mut client = if validate_certificates {
// Use platform-native verifier to validate certificates
config_builder.with_platform_verifier().unwrap().with_no_client_auth()
} else {
config_builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier))
.with_no_client_auth()
};
if with_alpn {
client.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
}
client
}
// Copied from reqwest: https://github.com/seanmonstar/reqwest/blob/595c80b1fbcdab73ac2ae93e4edc3406f453df25/src/tls.rs#L608
#[derive(Debug)]
struct NoVerifier;
impl ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA1,
SignatureScheme::ECDSA_SHA1_Legacy,
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::ED25519,
SignatureScheme::ED448,
]
}
}

View File

@@ -6,8 +6,6 @@ export type ActivateLicenseRequestPayload = { licenseKey: string, appVersion: st
export type ActivateLicenseResponsePayload = { activationId: string, };
export type CheckActivationResponsePayload = { active: boolean, };
export type DeactivateLicenseRequestPayload = { appVersion: string, appPlatform: string, };
export type LicenseCheckStatus = { "type": "personal_use", trial_ended: string, } | { "type": "commercial_use" } | { "type": "invalid_license" } | { "type": "trialing", end: string, };
export type LicenseCheckStatus = { "status": "personal_use", "data": { trial_ended: string, } } | { "status": "trialing", "data": { end: string, } } | { "status": "error", "data": { message: string, code: string, } } | { "status": "active", "data": { periodEnd: string, cancelAt: string | null, } } | { "status": "inactive", "data": { status: string, } } | { "status": "expired", "data": { changes: number, changesUrl: string | null, billingUrl: string, periodEnd: string, } } | { "status": "past_due", "data": { billingUrl: string, periodEnd: string, } };

View File

@@ -1,6 +1,6 @@
use crate::error::Error::{ClientError, ServerError};
use crate::error::Error::{ClientError, JsonError, ServerError};
use crate::error::Result;
use chrono::{NaiveDateTime, Utc};
use chrono::{DateTime, Utc};
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::ops::Add;
@@ -24,13 +24,6 @@ pub struct CheckActivationRequestPayload {
pub app_platform: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "license.ts")]
pub struct CheckActivationResponsePayload {
pub active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "license.ts")]
@@ -63,6 +56,49 @@ pub struct APIErrorResponsePayload {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "status", content = "data")]
#[ts(export, export_to = "license.ts")]
pub enum LicenseCheckStatus {
// Local Types
PersonalUse {
trial_ended: DateTime<Utc>,
},
Trialing {
end: DateTime<Utc>,
},
Error {
message: String,
code: String,
},
// Server Types
Active {
#[serde(rename = "periodEnd")]
period_end: DateTime<Utc>,
#[serde(rename = "cancelAt")]
cancel_at: Option<DateTime<Utc>>,
},
Inactive {
status: String,
},
Expired {
changes: i32,
#[serde(rename = "changesUrl")]
changes_url: Option<String>,
#[serde(rename = "billingUrl")]
billing_url: String,
#[serde(rename = "periodEnd")]
period_end: DateTime<Utc>,
},
PastDue {
#[serde(rename = "billingUrl")]
billing_url: String,
#[serde(rename = "periodEnd")]
period_end: DateTime<Utc>,
},
}
pub async fn activate_license<R: Runtime>(
window: &WebviewWindow<R>,
license_key: &str,
@@ -141,16 +177,6 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "license.ts")]
pub enum LicenseCheckStatus {
PersonalUse { trial_ended: NaiveDateTime },
CommercialUse,
InvalidLicense,
Trialing { end: NaiveDateTime },
}
pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<LicenseCheckStatus> {
let payload = CheckActivationRequestPayload {
app_platform: get_os_str().to_string(),
@@ -159,10 +185,10 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
let activation_id = get_activation_id(window.app_handle()).await;
let settings = window.db().get_settings();
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS));
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS)).and_utc();
let has_activation_id = !activation_id.is_empty();
let trial_period_active = Utc::now().naive_utc() < trial_end;
let trial_period_active = Utc::now() < trial_end;
match (has_activation_id, trial_period_active) {
(false, true) => Ok(LicenseCheckStatus::Trialing { end: trial_end }),
@@ -173,7 +199,7 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
info!("Checking license activation");
// A license has been activated, so let's check the license server
let client = yaak_api_client(window.app_handle())?;
let path = format!("/licenses/activations/{activation_id}/check");
let path = format!("/licenses/activations/{activation_id}/check-v2");
let response = client.post(build_url(&path)).json(&payload).send().await?;
if response.status().is_client_error() {
@@ -189,13 +215,14 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
return Err(ServerError);
}
let body: CheckActivationResponsePayload = response.json().await?;
if !body.active {
info!("Inactive License {:?}", body);
return Ok(LicenseCheckStatus::InvalidLicense);
let body_text = response.text().await?;
match serde_json::from_str::<LicenseCheckStatus>(&body_text) {
Ok(b) => Ok(b),
Err(e) => {
warn!("Failed to decode server response: {} {:?}", body_text, e);
Err(JsonError(e))
}
}
Ok(LicenseCheckStatus::CommercialUse)
}
}
}

View File

@@ -5,7 +5,7 @@ mod mac;
use crate::commands::{set_theme, set_title};
use std::sync::atomic::AtomicBool;
use tauri::{generate_handler, plugin, plugin::TauriPlugin, Manager, Runtime};
use tauri::{Manager, Runtime, generate_handler, plugin, plugin::TauriPlugin};
pub trait AppHandleMacWindowExt {
/// Sets whether to use the native titlebar
@@ -14,7 +14,9 @@ pub trait AppHandleMacWindowExt {
impl<R: Runtime> AppHandleMacWindowExt for tauri::AppHandle<R> {
fn set_native_titlebar(&self, enable: bool) {
self.state::<PluginState>().native_titlebar.store(enable, std::sync::atomic::Ordering::Relaxed);
self.state::<PluginState>()
.native_titlebar
.store(enable, std::sync::atomic::Ordering::Relaxed);
}
}
@@ -23,17 +25,21 @@ pub(crate) struct PluginState {
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
plugin::Builder::new("yaak-mac-window")
let mut builder = plugin::Builder::new("yaak-mac-window")
.setup(move |app, _| {
app.manage(PluginState { native_titlebar: AtomicBool::new(false) });
app.manage(PluginState {
native_titlebar: AtomicBool::new(false),
});
Ok(())
})
.invoke_handler(generate_handler![set_title, set_theme])
.on_window_ready(move |window| {
#[cfg(target_os = "macos")]
{
mac::setup_traffic_light_positioner(&window);
}
})
.build()
.invoke_handler(generate_handler![set_title, set_theme]);
#[cfg(target_os = "macos")]
{
builder = builder.on_window_ready(move |window| {
mac::setup_traffic_light_positioner(&window);
});
}
builder.build()
}

View File

@@ -2,6 +2,8 @@
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
@@ -62,7 +64,7 @@ export type ProxySetting = { "type": "enabled", http: string, https: string, aut
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN client_certificates TEXT DEFAULT '[]' NOT NULL;

View File

@@ -52,6 +52,26 @@ pub struct ProxySettingAuth {
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ClientCertificate {
pub host: String,
#[serde(default)]
pub port: Option<i32>,
#[serde(default)]
pub crt_file: Option<String>,
#[serde(default)]
pub key_file: Option<String>,
#[serde(default)]
pub pfx_file: Option<String>,
#[serde(default)]
pub passphrase: Option<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
@@ -106,6 +126,7 @@ pub struct Settings {
pub updated_at: NaiveDateTime,
pub appearance: String,
pub client_certificates: Vec<ClientCertificate>,
pub colored_methods: bool,
pub editor_font: Option<String>,
pub editor_font_size: i32,
@@ -158,10 +179,12 @@ impl UpsertModelInfo for Settings {
None => None,
Some(p) => Some(serde_json::to_string(&p)?),
};
let client_certificates = serde_json::to_string(&self.client_certificates)?;
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
(Appearance, self.appearance.as_str().into()),
(ClientCertificates, client_certificates.into()),
(EditorFontSize, self.editor_font_size.into()),
(EditorKeymap, self.editor_keymap.to_string().into()),
(EditorSoftWrap, self.editor_soft_wrap.into()),
@@ -188,6 +211,7 @@ impl UpsertModelInfo for Settings {
vec![
SettingsIden::UpdatedAt,
SettingsIden::Appearance,
SettingsIden::ClientCertificates,
SettingsIden::EditorFontSize,
SettingsIden::EditorKeymap,
SettingsIden::EditorSoftWrap,
@@ -215,6 +239,7 @@ impl UpsertModelInfo for Settings {
Self: Sized,
{
let proxy: Option<String> = row.get("proxy")?;
let client_certificates: String = row.get("client_certificates")?;
let editor_keymap: String = row.get("editor_keymap")?;
Ok(Self {
id: row.get("id")?,
@@ -222,6 +247,7 @@ impl UpsertModelInfo for Settings {
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
appearance: row.get("appearance")?,
client_certificates: serde_json::from_str(&client_certificates).unwrap_or_default(),
editor_font_size: row.get("editor_font_size")?,
editor_font: row.get("editor_font")?,
editor_keymap: EditorKeymap::from_str(editor_keymap.as_str()).unwrap(),

View File

@@ -18,6 +18,7 @@ impl<'a> DbContext<'a> {
updated_at: Default::default(),
appearance: "system".to_string(),
client_certificates: Vec::new(),
editor_font_size: 12,
editor_font: None,
editor_keymap: EditorKeymap::Default,

View File

@@ -622,6 +622,14 @@ impl PluginManager {
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> Result<GetHttpAuthenticationConfigResponse> {
if auth_name == "none" {
return Ok(GetHttpAuthenticationConfigResponse {
args: Vec::new(),
plugin_ref_id: "auth-none".to_string(),
actions: None,
});
}
let results = self.get_http_authentication_summaries(window).await?;
let plugin = results
.iter()

View File

@@ -0,0 +1,16 @@
[package]
name = "yaak-tls"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
log = { workspace = true }
p12 = "0.6.3"
rustls = { workspace = true, default-features = false, features = ["ring"] }
rustls-pemfile = "2"
rustls-platform-verifier = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = "2.0.17"
url = "2.5"
yaak-models = { workspace = true }

View File

@@ -0,0 +1,26 @@
use serde::{Serialize, Serializer};
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Rustls error: {0}")]
RustlsError(#[from] rustls::Error),
#[error("I/O error: {0}")]
IOError(#[from] io::Error),
#[error("TLS error: {0}")]
GenericError(String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,279 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use log::debug;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::ring;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use rustls_platform_verifier::BuilderVerifierExt;
use std::fs;
use std::io::BufReader;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
pub mod error;
#[derive(Clone, Default)]
pub struct ClientCertificateConfig {
pub crt_file: Option<String>,
pub key_file: Option<String>,
pub pfx_file: Option<String>,
pub passphrase: Option<String>,
}
pub fn get_tls_config(
validate_certificates: bool,
with_alpn: bool,
client_cert: Option<ClientCertificateConfig>,
) -> Result<ClientConfig> {
let maybe_client_cert = load_client_cert(client_cert)?;
let mut client = if validate_certificates {
build_with_validation(maybe_client_cert)
} else {
build_without_validation(maybe_client_cert)
}?;
if with_alpn {
client.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
}
Ok(client)
}
fn build_with_validation(
client_cert: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig> {
let arc_crypto_provider = Arc::new(ring::default_provider());
let builder = ClientConfig::builder_with_provider(arc_crypto_provider)
.with_safe_default_protocol_versions()?
.with_platform_verifier()?;
if let Some((certs, key)) = client_cert {
return Ok(builder.with_client_auth_cert(certs, key)?);
}
Ok(builder.with_no_client_auth())
}
fn build_without_validation(
client_cert: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig> {
let arc_crypto_provider = Arc::new(ring::default_provider());
let builder = ClientConfig::builder_with_provider(arc_crypto_provider)
.with_safe_default_protocol_versions()?
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier));
if let Some((certs, key)) = client_cert {
return Ok(builder.with_client_auth_cert(certs, key)?);
}
Ok(builder.with_no_client_auth())
}
fn load_client_cert(
client_cert: Option<ClientCertificateConfig>,
) -> Result<Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>> {
let config = match client_cert {
None => return Ok(None),
Some(c) => c,
};
// Try PFX/PKCS12 first
if let Some(pfx_path) = &config.pfx_file {
if !pfx_path.is_empty() {
return Ok(Some(load_pkcs12(pfx_path, config.passphrase.as_deref().unwrap_or(""))?));
}
}
// Try CRT + KEY files
if let (Some(crt_path), Some(key_path)) = (&config.crt_file, &config.key_file) {
if !crt_path.is_empty() && !key_path.is_empty() {
return Ok(Some(load_pem_files(crt_path, key_path)?));
}
}
Ok(None)
}
fn load_pem_files(
crt_path: &str,
key_path: &str,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
// Load certificates
let crt_file = fs::File::open(Path::new(crt_path))?;
let mut crt_reader = BufReader::new(crt_file);
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut crt_reader).filter_map(|r| r.ok()).collect();
if certs.is_empty() {
return Err(GenericError("No certificates found in CRT file".to_string()));
}
// Load private key
let key_data = fs::read(Path::new(key_path))?;
let key = load_private_key(&key_data)?;
Ok((certs, key))
}
fn load_private_key(data: &[u8]) -> Result<PrivateKeyDer<'static>> {
let mut reader = BufReader::new(data);
// Try PKCS8 first
if let Some(key) = rustls_pemfile::pkcs8_private_keys(&mut reader).filter_map(|r| r.ok()).next()
{
return Ok(PrivateKeyDer::Pkcs8(key));
}
// Reset reader and try RSA
let mut reader = BufReader::new(data);
if let Some(key) = rustls_pemfile::rsa_private_keys(&mut reader).filter_map(|r| r.ok()).next() {
return Ok(PrivateKeyDer::Pkcs1(key));
}
// Reset reader and try EC
let mut reader = BufReader::new(data);
if let Some(key) = rustls_pemfile::ec_private_keys(&mut reader).filter_map(|r| r.ok()).next() {
return Ok(PrivateKeyDer::Sec1(key));
}
Err(GenericError("Could not parse private key".to_string()))
}
fn load_pkcs12(
path: &str,
passphrase: &str,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let data = fs::read(Path::new(path))?;
let pfx = p12::PFX::parse(&data)
.map_err(|e| GenericError(format!("Failed to parse PFX: {:?}", e)))?;
let keys = pfx
.key_bags(passphrase)
.map_err(|e| GenericError(format!("Failed to extract keys: {:?}", e)))?;
let certs = pfx
.cert_x509_bags(passphrase)
.map_err(|e| GenericError(format!("Failed to extract certs: {:?}", e)))?;
if keys.is_empty() {
return Err(GenericError("No private key found in PFX".to_string()));
}
if certs.is_empty() {
return Err(GenericError("No certificates found in PFX".to_string()));
}
// Convert certificates - p12 crate returns Vec<u8> for each cert
let cert_ders: Vec<CertificateDer<'static>> =
certs.into_iter().map(|c| CertificateDer::from(c)).collect();
// Convert key - the p12 crate returns raw key bytes
let key_bytes = keys.into_iter().next().unwrap();
let key = PrivateKeyDer::Pkcs8(key_bytes.into());
Ok((cert_ders, key))
}
// Copied from reqwest: https://github.com/seanmonstar/reqwest/blob/595c80b1fbcdab73ac2ae93e4edc3406f453df25/src/tls.rs#L608
#[derive(Debug)]
struct NoVerifier;
impl ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA1,
SignatureScheme::ECDSA_SHA1_Legacy,
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::ED25519,
SignatureScheme::ED448,
]
}
}
pub fn find_client_certificate(
url_string: &str,
certificates: &[yaak_models::models::ClientCertificate],
) -> Option<ClientCertificateConfig> {
let url = url::Url::from_str(url_string).ok()?;
let host = url.host_str()?;
let port = url.port_or_known_default();
for cert in certificates {
if !cert.enabled {
debug!("Client certificate is disabled, skipping");
continue;
}
// Match host (case-insensitive)
if !cert.host.eq_ignore_ascii_case(host) {
debug!("Client certificate host does not match {} != {} (cert)", host, cert.host);
continue;
}
// Match port if specified in the certificate config
let cert_port = cert.port.unwrap_or(443);
if let Some(url_port) = port {
if cert_port != url_port as i32 {
debug!(
"Client certificate port does not match {} != {} (cert)",
url_port, cert_port
);
continue;
}
}
// Found a matching certificate
debug!("Found matching client certificate host={} port={}", host, port.unwrap_or(443));
return Some(ClientCertificateConfig {
crt_file: cert.crt_file.clone(),
key_file: cert.key_file.clone(),
pfx_file: cert.pfx_file.clone(),
passphrase: cert.passphrase.clone(),
});
}
debug!("No matching client certificate found for {}", url_string);
None
}

View File

@@ -8,7 +8,7 @@ publish = false
[dependencies]
futures-util = "0.3.31"
log = { workspace = true }
md5 = "0.7.0"
md5 = "0.8.0"
reqwest_cookie_store = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -17,6 +17,7 @@ thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "time", "test-util"] }
tokio-tungstenite = { version = "0.26.2", default-features = false, features = ["rustls-tls-native-roots", "connect"] }
yaak-http = { workspace = true }
yaak-tls = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }

View File

@@ -23,6 +23,7 @@ use yaak_plugins::events::{
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
#[tauri::command]
pub(crate) async fn upsert_request<R: Runtime>(
@@ -196,6 +197,7 @@ pub(crate) async fn connect<R: Runtime>(
environment_id,
)?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?;
let request = render_websocket_request(
@@ -363,6 +365,8 @@ pub(crate) async fn connect<R: Runtime>(
}
}
let client_cert = find_client_certificate(url.as_str(), &settings.client_certificates);
let response = match ws_manager
.connect(
&connection.id,
@@ -370,6 +374,7 @@ pub(crate) async fn connect<R: Runtime>(
headers,
receive_tx,
workspace.setting_validate_certificates,
client_cert,
)
.await
{

View File

@@ -1,3 +1,4 @@
use crate::error::Result;
use log::info;
use std::sync::Arc;
use tauri::http::HeaderMap;
@@ -9,6 +10,7 @@ use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use tokio_tungstenite::{
Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config,
};
use yaak_tls::{ClientCertificateConfig, get_tls_config};
// Enabling ALPN breaks websocket requests
const WITH_ALPN: bool = false;
@@ -17,9 +19,10 @@ pub(crate) async fn ws_connect(
url: &str,
headers: HeaderMap<HeaderValue>,
validate_certificates: bool,
) -> crate::error::Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
info!("Connecting to WS {url}");
let tls_config = yaak_http::tls::get_config(validate_certificates, WITH_ALPN);
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
let mut req = url.into_client_request()?;
let req_headers = req.headers_mut();
@@ -36,5 +39,12 @@ pub(crate) async fn ws_connect(
Some(Connector::Rustls(Arc::new(tls_config))),
)
.await?;
info!(
"Connected to WS {url} validate_certificates={} client_cert={}",
validate_certificates,
client_cert.is_some()
);
Ok((stream, response))
}

View File

@@ -16,6 +16,9 @@ pub enum Error {
#[error(transparent)]
TemplateError(#[from] yaak_templates::error::Error),
#[error(transparent)]
TlsError(#[from] yaak_tls::error::Error),
#[error("WebSocket error: {0}")]
GenericError(String),
}

View File

@@ -12,6 +12,7 @@ use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::client::Response;
use tokio_tungstenite::tungstenite::http::{HeaderMap, HeaderValue};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig;
#[derive(Clone)]
pub struct WebsocketManager {
@@ -35,10 +36,12 @@ impl WebsocketManager {
headers: HeaderMap<HeaderValue>,
receive_tx: mpsc::Sender<Message>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response> {
let tx = receive_tx.clone();
let (stream, response) = ws_connect(url, headers, validate_certificates).await?;
let (stream, response) =
ws_connect(url, headers, validate_certificates, client_cert).await?;
let (write, mut read) = stream.split();
self.connections.lock().await.insert(id.to_string(), write);

View File

@@ -1,22 +1,86 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license';
import { settingsAtom } from '@yaakapp-internal/models';
import { differenceInCalendarDays } from 'date-fns';
import { formatDate } from 'date-fns/format';
import { useAtomValue } from 'jotai';
import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
import { jotaiStore } from '../lib/jotai';
import { CargoFeature } from './CargoFeature';
import type { ButtonProps } from './core/Button';
import { Dropdown, type DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { PillButton } from './core/PillButton';
const details: Record<
LicenseCheckStatus['type'],
{ label: ReactNode; color: ButtonProps['color'] } | null
> = {
commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' },
personal_use: { label: 'Personal Use', color: 'notice' },
trialing: { label: 'Commercial Trial', color: 'secondary' },
};
const dismissedAtom = atomWithKVStorage<string | null>('dismissed_license_expired', null);
function getDetail(
data: LicenseCheckStatus,
dismissedExpired: string | null,
): { label: ReactNode; color: ButtonProps['color']; options?: DropdownItem[] } | null | undefined {
const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null;
switch (data.status) {
case 'active':
return null;
case 'personal_use':
return { label: 'Personal Use', color: 'notice' };
case 'trialing':
return { label: 'Commercial Trial', color: 'secondary' };
case 'error':
return { label: 'Error', color: 'danger' };
case 'inactive':
return { label: 'Personal Use', color: 'notice' };
case 'past_due':
return { label: 'Past Due', color: 'danger' };
case 'expired':
// Don't show the expired message if it's been less than 14 days since the last dismissal
if (dismissedAt && differenceInCalendarDays(new Date(), dismissedAt) < 14) {
return null;
}
return {
color: 'notice',
label: data.data.changes > 0 ? 'Updates Paused' : 'License Expired',
options: [
{
label: `${data.data.changes} New Updates`,
color: 'success',
leftSlot: <Icon icon="gift" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changes === 0 || data.data.changesUrl == null,
onSelect: () => openUrl(data.data.changesUrl ?? ''),
},
{
type: 'separator',
label: `License expired ${formatDate(data.data.periodEnd, 'MMM dd, yyyy')}`,
},
{
label: <div className="min-w-[12rem]">Renew License</div>,
leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null,
onSelect: () => openUrl(data.data.billingUrl),
},
{
label: 'Enter License Key',
leftSlot: <Icon icon="key_round" />,
hidden: data.data.changesUrl == null,
onSelect: openLicenseDialog,
},
{ type: 'separator' },
{
label: <span className="text-text-subtle">Remind me Later</span>,
leftSlot: <Icon icon="alarm_clock" className="text-text-subtle" />,
onSelect: () => jotaiStore.set(dismissedAtom, new Date().toISOString()),
},
],
};
}
}
export function LicenseBadge() {
return (
@@ -29,10 +93,15 @@ export function LicenseBadge() {
function LicenseBadgeCmp() {
const { check } = useLicense();
const settings = useAtomValue(settingsAtom);
const dismissed = useAtomValue(dismissedAtom);
// Dismissed license badge
if (settings.hideLicenseBadge) {
return null;
}
if (check.error) {
// Failed to check for license. Probably a network or server error so just don't
// show anything.
// Failed to check for license. Probably a network or server error, so just don't show anything.
return null;
}
@@ -41,19 +110,30 @@ function LicenseBadgeCmp() {
return null;
}
// Dismissed license badge
if (settings.hideLicenseBadge) {
return null;
}
const detail = details[check.data.type];
const detail = getDetail(check.data, dismissed);
if (detail == null) {
return null;
}
if (detail.options && detail.options.length > 0) {
return (
<Dropdown items={detail.options}>
<PillButton color={detail.color}>
<div className="flex items-center gap-0.5">
{detail.label} <Icon icon="chevron_down" className="opacity-60" />
</div>
</PillButton>
</Dropdown>
);
}
return (
<PillButton color={detail.color} onClick={() => openSettings.mutate('license')}>
<PillButton color={detail.color} onClick={openLicenseDialog}>
{detail.label}
</PillButton>
);
}
function openLicenseDialog() {
openSettings.mutate('license');
}

View File

@@ -1,15 +1,19 @@
import { useSearch } from '@tanstack/react-router';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import { useLicense } from '@yaakapp-internal/license';
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize';
import { CountBadge } from '../core/CountBadge';
import { HStack } from '../core/Stacks';
import type { TabItem } from '../core/Tabs/Tabs';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize';
import { SettingsCertificates } from './SettingsCertificates';
import { SettingsGeneral } from './SettingsGeneral';
import { SettingsInterface } from './SettingsInterface';
import { SettingsLicense } from './SettingsLicense';
@@ -25,14 +29,26 @@ const TAB_GENERAL = 'general';
const TAB_INTERFACE = 'interface';
const TAB_THEME = 'theme';
const TAB_PROXY = 'proxy';
const TAB_CERTIFICATES = 'certificates';
const TAB_PLUGINS = 'plugins';
const TAB_LICENSE = 'license';
const tabs = [TAB_GENERAL, TAB_THEME, TAB_INTERFACE, TAB_PROXY, TAB_PLUGINS, TAB_LICENSE] as const;
const tabs = [
TAB_GENERAL,
TAB_THEME,
TAB_INTERFACE,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_PLUGINS,
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
const [tab, setTab] = useState<string | undefined>(tabFromQuery);
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
@@ -79,6 +95,16 @@ export default function Settings({ hide }: Props) {
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
rightSlot:
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.length} />
) : value === TAB_PROXY && settings.proxy?.type === 'enabled' ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === 'personal_use' ? (
<CountBadge count color="notice" />
) : null,
}),
)}
>
@@ -97,6 +123,9 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4">
<SettingsCertificates />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
<SettingsLicense />
</TabContent>

View File

@@ -0,0 +1,253 @@
import type { ClientCertificate } from '@yaakapp-internal/models';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useRef } from 'react';
import { showConfirmDelete } from '../../lib/confirm';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { DetailsBanner } from '../core/DetailsBanner';
import { Heading } from '../core/Heading';
import { IconButton } from '../core/IconButton';
import { InlineCode } from '../core/InlineCode';
import { PlainInput } from '../core/PlainInput';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
import { SelectFile } from '../SelectFile';
function createEmptyCertificate(): ClientCertificate {
return {
host: '',
port: null,
crtFile: null,
keyFile: null,
pfxFile: null,
passphrase: null,
enabled: true,
};
}
interface CertificateEditorProps {
certificate: ClientCertificate;
index: number;
onUpdate: (index: number, cert: ClientCertificate) => void;
onRemove: (index: number) => void;
}
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
const updateField = <K extends keyof ClientCertificate>(
field: K,
value: ClientCertificate[K],
) => {
onUpdate(index, { ...certificate, [field]: value });
};
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
const hasCrtKey = Boolean(
(certificate.crtFile && certificate.crtFile.length > 0) ||
(certificate.keyFile && certificate.keyFile.length > 0),
);
// Determine certificate type for display
const certType = hasPfx ? 'PFX' : hasCrtKey ? 'CERT' : null;
const defaultOpen = useRef<boolean>(!certificate.host);
return (
<DetailsBanner
open={defaultOpen.current}
summary={
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
<HStack space={1.5}>
<Checkbox
className="ml-1"
checked={certificate.enabled ?? true}
title={certificate.enabled ? 'Disable certificate' : 'Enable certificate'}
hideLabel
onChange={(enabled) => updateField('enabled', enabled)}
/>
{certificate.host ? (
<InlineCode>
{certificate.host || <>&nbsp;</>}
{certificate.port != null && `:${certificate.port}`}
</InlineCode>
) : (
<span className="italic text-sm text-text-subtlest">Configure Certificate</span>
)}
{certType && <InlineCode>{certType}</InlineCode>}
</HStack>
<IconButton
icon="trash"
size="sm"
title="Remove certificate"
className="text-text-subtlest -mr-2"
onClick={() => onRemove(index)}
/>
</HStack>
}
>
<VStack space={3} className="mt-2">
<HStack space={2} alignItems="end">
<PlainInput
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
https://
</div>
}
validate={(value) => {
if (!value) return false;
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
return true;
}}
label="Host"
placeholder="example.com"
size="sm"
required
defaultValue={certificate.host}
onChange={(host) => updateField('host', host)}
/>
<PlainInput
label="Port"
hideLabel
validate={(value) => {
if (!value) return true;
if (Number.isNaN(parseInt(value, 10))) return false;
return true;
}}
placeholder="443"
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
:
</div>
}
size="sm"
className="w-24"
defaultValue={certificate.port?.toString() ?? ''}
onChange={(port) => updateField('port', port ? parseInt(port, 10) : null)}
/>
</HStack>
<Separator className="my-3" />
<VStack space={2}>
<SelectFile
label="CRT File"
noun="Cert"
filePath={certificate.crtFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField('crtFile', filePath)}
/>
<SelectFile
label="KEY File"
noun="Key"
filePath={certificate.keyFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField('keyFile', filePath)}
/>
</VStack>
<Separator className="my-3" />
<SelectFile
label="PFX File"
noun="Key"
filePath={certificate.pfxFile ?? null}
size="sm"
disabled={hasCrtKey}
onChange={({ filePath }) => updateField('pfxFile', filePath)}
/>
<PlainInput
label="Passphrase"
size="sm"
type="password"
defaultValue={certificate.passphrase ?? ''}
onChange={(passphrase) => updateField('passphrase', passphrase || null)}
/>
</VStack>
</DetailsBanner>
);
}
export function SettingsCertificates() {
const settings = useAtomValue(settingsAtom);
const certificates = settings.clientCertificates ?? [];
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
await patchModel(settings, { clientCertificates: newCertificates });
};
const handleAdd = async () => {
const newCert = createEmptyCertificate();
await updateCertificates([...certificates, newCert]);
};
const handleUpdate = async (index: number, cert: ClientCertificate) => {
const newCertificates = [...certificates];
newCertificates[index] = cert;
await updateCertificates(newCertificates);
};
const handleRemove = async (index: number) => {
const cert = certificates[index];
if (cert == null) return;
const host = cert.host || 'this certificate';
const port = cert.port != null ? `:${cert.port}` : '';
const confirmed = await showConfirmDelete({
id: 'confirm-remove-certificate',
title: 'Delete Certificate',
description: (
<>
Permanently delete certificate for{' '}
<InlineCode>
{host}
{port}
</InlineCode>
?
</>
),
});
if (!confirmed) return;
const newCertificates = certificates.filter((_, i) => i !== index);
await updateCertificates(newCertificates);
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
{certificates.length > 0 && (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
// biome-ignore lint/suspicious/noArrayIndexKey: Index is fine here
key={index}
certificate={cert}
index={index}
onUpdate={handleUpdate}
onRemove={handleRemove}
/>
))}
</VStack>
)}
</VStack>
);
}

View File

@@ -26,6 +26,10 @@ export function SettingsGeneral() {
return (
<VStack space={1.5} className="mb-4">
<div className="mb-4">
<Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
</div>
<CargoFeature feature="updater">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<Select

View File

@@ -13,6 +13,7 @@ import { invokeCmd } from '../../lib/tauri';
import { CargoFeature } from '../CargoFeature';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading';
import { Icon } from '../core/Icon';
import { Link } from '../core/Link';
import { Select } from '../core/Select';
@@ -42,6 +43,10 @@ export function SettingsInterface() {
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<Select
name="switchWorkspaceBehavior"
label="Open workspace behavior"
@@ -199,7 +204,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
function LicenseSettings({ settings }: { settings: Settings }) {
const license = useLicense();
if (license.check.data?.type !== 'personal_use') {
if (license.check.data?.status !== 'personal_use') {
return null;
}

View File

@@ -1,6 +1,7 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { useLicense } from '@yaakapp-internal/license';
import { differenceInDays } from 'date-fns';
import { formatDate } from 'date-fns/format';
import { useState } from 'react';
import { useToggle } from '../../hooks/useToggle';
import { pluralizeCount } from '../../lib/pluralize';
@@ -31,71 +32,120 @@ function SettingsLicenseCmp() {
return null;
}
const renderBanner = () => {
if (!check.data) return null;
switch (check.data.status) {
case 'active':
return <Banner color="success">Your license is active 🥳</Banner>;
case 'trialing':
return (
<Banner color="info" className="@container flex items-center gap-x-5 max-w-xl">
<LocalImage src="static/greg.jpeg" className="hidden @sm:block rounded-full h-14 w-14" />
<p className="w-full">
<strong>
{pluralizeCount('day', differenceInDays(check.data.data.end, new Date()))}
</strong>{' '}
left to evaluate Yaak for commercial use.
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href="mailto:support@yaak.app">
Contact Support
</Link>
<Icon icon="dot" size="sm" color="secondary" />
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}
>
Learn More
</Link>
</div>
</p>
</Banner>
);
case 'personal_use':
return (
<Banner color="notice" className="@container flex items-center gap-x-5 max-w-xl">
<LocalImage src="static/greg.jpeg" className="hidden @sm:block rounded-full h-14 w-14" />
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
You may continue using Yaak for personal use free, forever.
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href="mailto:support@yaak.app">
Contact Support
</Link>
<Icon icon="dot" size="sm" color="secondary" />
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}
>
Learn More
</Link>
</div>
</p>
</Banner>
);
case 'inactive':
return (
<Banner color="danger">
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{' '}
for more details
</Banner>
);
case 'expired':
return (
<Banner color="notice">
Your license expired{' '}
<strong>{formatDate(check.data.data.periodEnd, 'MMMM dd, yyyy')}</strong>. Please{' '}
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
updates.
{check.data.data.changesUrl && (
<>
<br />
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
</>
)}
</Banner>
);
case 'past_due':
return (
<Banner color="danger">
<strong>Your payment method needs attention.</strong>
<br />
To re-activate your license, please{' '}
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
</Banner>
);
case 'error':
return (
<Banner color="danger">
License check failed: {check.data.data.message} (Code: {check.data.data.code})
</Banner>
);
}
};
return (
<div className="flex flex-col gap-6 max-w-xl">
{check.data?.type === 'commercial_use' ? (
<Banner color="success">Your license is active 🥳</Banner>
) : check.data?.type === 'trialing' ? (
<Banner color="info" className="@container flex items-center gap-x-5 max-w-xl">
<LocalImage src="static/greg.jpeg" className="hidden @sm:block rounded-full h-14 w-14" />
<p className="w-full">
<strong>{pluralizeCount('day', differenceInDays(check.data.end, new Date()))}</strong>{' '}
left to evaluate Yaak for commercial use.
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href="mailto:support@yaak.app">
Contact Support
</Link>
<Icon icon="dot" size="sm" color="secondary" />
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data?.type ?? ''}`}
>
Learn More
</Link>
</div>
</p>
</Banner>
) : check.data?.type === 'personal_use' ? (
<Banner color="notice" className="@container flex items-center gap-x-5 max-w-xl">
<LocalImage src="static/greg.jpeg" className="hidden @sm:block rounded-full h-14 w-14" />
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
You may continue using Yaak for personal use free, forever.
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href="mailto:support@yaak.app">
Contact Support
</Link>
<Icon icon="dot" size="sm" color="secondary" />
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data?.type ?? ''}`}
>
Learn More
</Link>
</div>
</p>
</Banner>
) : null}
{renderBanner()}
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
{check.data?.type === 'invalid_license' && (
<Banner color="danger">
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link> for
more details
</Banner>
)}
{check.data?.type === 'commercial_use' ? (
{check.data?.status === 'active' ? (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
Deactivate License
@@ -120,7 +170,7 @@ function SettingsLicenseCmp() {
rightSlot={<Icon icon="external_link" />}
onClick={() =>
openUrl(
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.type ?? ''}`,
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ''}`,
)
}
>

View File

@@ -2,6 +2,7 @@ import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading';
import { InlineCode } from '../core/InlineCode';
import { PlainInput } from '../core/PlainInput';
import { Select } from '../core/Select';
@@ -13,6 +14,13 @@ export function SettingsProxy() {
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<Select
name="proxy"
label="Proxy"

View File

@@ -5,9 +5,11 @@ import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useResolvedAppearance } from '../../hooks/useResolvedAppearance';
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
import type { ButtonProps } from '../core/Button';
import { Heading } from '../core/Heading';
import type { IconProps } from '../core/Icon';
import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton';
import { Link } from '../core/Link';
import type { SelectProps } from '../core/Select';
import { Select } from '../core/Select';
import { HStack, VStack } from '../core/Stacks';
@@ -69,6 +71,15 @@ export function SettingsTheme() {
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{' '}
<Link href="https://feedback.yaak.app/help/articles/6911763-plugins-quick-start">
Create Your Own
</Link>
</p>
</div>
<Select
name="appearance"
label="Appearance"

View File

@@ -67,7 +67,7 @@ export function SettingsDropdown() {
{
label: 'Purchase License',
color: 'success',
hidden: check.data == null || check.data.type === 'commercial_use',
hidden: check.data == null || check.data.status === 'active',
leftSlot: <Icon icon="circle_dollar_sign" />,
onSelect: () => openSettings.mutate('license'),
},

View File

@@ -28,7 +28,10 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const showEncryptionSetup =
workspace?.encryptionKeyChallenge != null && workspaceMeta?.encryptionKey == null;
workspace != null &&
workspaceMeta != null &&
workspace.encryptionKeyChallenge != null &&
workspaceMeta.encryptionKey == null;
return (
<div

View File

@@ -1,11 +1,13 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
interface Props {
count: number | true;
className?: string;
color?: Color;
}
export function CountBadge({ count, className }: Props) {
export function CountBadge({ count, className, color }: Props) {
if (count === 0) return null;
return (
<div
@@ -13,10 +15,21 @@ export function CountBadge({ count, className }: Props) {
className={classNames(
className,
'flex items-center',
'opacity-70 border border-border-subtle text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
'opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
color == null && 'border-border-subtle',
color === 'primary' && 'text-primary',
color === 'secondary' && 'text-secondary',
color === 'success' && 'text-success',
color === 'notice' && 'text-notice',
color === 'warning' && 'text-warning',
color === 'danger' && 'text-danger',
)}
>
{count === true ? <div aria-hidden className="rounded-full h-1 w-1 bg-text-subtle" /> : count}
{count === true ? (
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
) : (
count
)}
</div>
);
}

View File

@@ -24,7 +24,7 @@ export function DetailsBanner({ className, color, summary, children, ...extraPro
/>
{summary}
</summary>
<div className="mt-1.5">{children}</div>
<div className="mt-1.5 pb-2">{children}</div>
</details>
</Banner>
);

View File

@@ -1,6 +1,7 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import {
AlarmClockIcon,
AlertTriangleIcon,
ArchiveIcon,
ArrowBigDownDashIcon,
@@ -67,6 +68,7 @@ import {
FolderSymlinkIcon,
FolderSyncIcon,
FolderUpIcon,
GiftIcon,
GitBranchIcon,
GitBranchPlusIcon,
GitCommitIcon,
@@ -129,6 +131,7 @@ import type { CSSProperties, HTMLAttributes } from 'react';
import { memo } from 'react';
const icons = {
alarm_clock: AlarmClockIcon,
alert_triangle: AlertTriangleIcon,
archive: ArchiveIcon,
arrow_big_down_dash: ArrowBigDownDashIcon,
@@ -194,6 +197,7 @@ const icons = {
folder_symlink: FolderSymlinkIcon,
folder_sync: FolderSyncIcon,
folder_up: FolderUpIcon,
gift: GiftIcon,
git_branch: GitBranchIcon,
git_branch_plus: GitBranchPlusIcon,
git_commit: GitCommitIcon,

View File

@@ -162,6 +162,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
'x-theme-input',
'relative w-full rounded-md text',
'border',
'overflow-hidden',
focused ? 'border-border-focus' : 'border-border-subtle',
hasChanged && 'has-[:invalid]:border-danger', // For built-in HTML validation
size === 'md' && 'min-h-md',

View File

@@ -47,10 +47,14 @@ export function useGrpc(
const reflect = useQuery<ReflectResponseService[], string>({
enabled: req != null,
queryKey: ['grpc_reflect', req?.id ?? 'n/a', debouncedUrl, protoFiles],
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
queryFn: () => {
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
return minPromiseMillis<ReflectResponseService[]>(
invokeCmd('cmd_grpc_reflect', { requestId, protoFiles, environmentId }),
invokeCmd('cmd_grpc_reflect', { requestId, protoFiles, environmentId, skipCache: true }),
300,
);
},

View File

@@ -1,7 +1,9 @@
import { setWindowTitle } from '@yaakapp-internal/mac-window';
import { settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { appInfo } from '../lib/appInfo';
import { jotaiStore } from '../lib/jotai';
import { resolvedModelName } from '../lib/resolvedModelName';
import { useActiveEnvironment } from './useActiveEnvironment';
import { activeRequestAtom } from './useActiveRequest';
@@ -13,11 +15,13 @@ export function useSyncWorkspaceRequestTitle() {
const activeRequest = useAtomValue(activeRequestAtom);
useEffect(() => {
const settings = jotaiStore.get(settingsAtom);
let newTitle = activeWorkspace ? activeWorkspace.name : 'Yaak';
if (activeEnvironment) {
newTitle += ` [${activeEnvironment.name}]`;
newTitle += ` (${activeEnvironment.name})`;
}
if (activeRequest) {
if (!settings.useNativeTitlebar && activeRequest) {
newTitle += ` ${resolvedModelName(activeRequest)}`;
}

View File

@@ -27,14 +27,14 @@
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-log": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-os": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.1",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-log": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.3",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"cm6-graphql": "^0.2.1",