Compare commits

..

27 Commits

Author SHA1 Message Date
Gregory Schier
eb8153f409 Better trial activation flows 2025-02-25 22:16:55 -08:00
Gregory Schier
80de232bec Fix dropdown button icon 2025-02-25 19:52:57 -08:00
Gregory Schier
7af8c95fea Allow opening workspace if sync dir not empty 2025-02-25 06:54:30 -08:00
Gregory Schier
2db72fe6ef Fix WS duplication from context menu 2025-02-25 06:10:35 -08:00
Gregory Schier
d297e92a5a Fix content type parsing exception 2025-02-24 22:44:58 -08:00
Gregory Schier
7e1da4395d Build OAuth 2 plugin 2025-02-24 22:34:29 -08:00
Gregory Schier
7f8b0479e1 Plugin window data directory key 2025-02-24 22:32:40 -08:00
Gregory Schier
c8d6183456 Reduce plugin runtime memory 2025-02-24 12:20:47 -08:00
Gregory Schier
9d5f7784c4 Fix code splitting from tanstack/router migration 2025-02-24 07:12:45 -08:00
Gregory Schier
05ac836265 Remove analytics and add more update headers 2025-02-24 06:31:49 -08:00
Gregory Schier
af7782c93b Better license flows 2025-02-24 05:59:15 -08:00
Gregory Schier
2b1431d041 Merge remote-tracking branch 'origin/master' 2025-02-23 06:25:59 -08:00
Gregory Schier
9d8b7a5265 Tweak getting content type 2025-02-23 06:25:53 -08:00
dependabot[bot]
95c12ad291 Bump openssl from 0.10.66 to 0.10.70 in /src-tauri (#161)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-23 06:09:26 -08:00
Gregory Schier
dac2cec52f Merge remote-tracking branch 'origin/master' 2025-02-23 06:06:43 -08:00
Gregory Schier
efe4eef1b7 Fix deleting selected environment 2025-02-23 06:05:01 -08:00
Hao Xiang
a0e196a9e7 adding alternate key combinations for special shift (#173) 2025-02-22 07:00:04 -08:00
Gregory Schier
c6427dc724 Update README.md 2025-02-21 14:02:29 -08:00
Gregory Schier
8ce1e22b4e Update README.md 2025-02-21 13:57:51 -08:00
Gregory Schier
022d725e03 Update issue templates 2025-02-21 13:53:54 -08:00
Gregory Schier
ed7fdb1b4c Only close brackets for json-like langs
Fixes #162
2025-02-21 13:50:21 -08:00
Gregory Schier
e510204b8c More monospace fallbacks
Closes #167
2025-02-21 13:36:54 -08:00
Gregory Schier
d31b4448df Pass templating vars to recursive children (closes #171) 2025-02-21 13:34:15 -08:00
Gregory Schier
e420a0a45e Don't expand the directory setting when creating workspace 2025-02-21 13:18:47 -08:00
Gregory Schier
84ecbe0cd6 Better querystring import (https://feedback.yaak.app/p/url-pasted-params-parsed-incorrectly) 2025-02-21 13:16:09 -08:00
Gregory Schier
6a63cc26b9 Fix commit-and-push loading state 2025-02-19 10:35:41 -08:00
Gregory Schier
8ed0fd55c3 Remove environments from synced folder, and stop syncing 2025-02-19 10:35:31 -08:00
128 changed files with 1632 additions and 1586 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -7,25 +7,24 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
## Feature Overview
🪂 Import data from Postman, Insomnia, OpenAPI, Swagger, or Curl.<br/>
📤 Send requests via REST, GraphQL, Server Sent Events (SSE), WebSockets, or gRPC.<br/>
🔐 Automatically authorize requests with OAuth 2.0, JWT tokens, Basic Auth, and more.<br/>
🔎 Filter response bodies using JSONPath or XPath queries.<br/>
⛓️ Chain together multiple requests to dynamically reference values.<br/>
📂 Organize requests into workspaces and nested folders.<br/>
🧮 Use environment variables to easily switch between Prod and Dev.<br/>
🏷️ Send dynamic values like UUIDs or timestamps using template tags.<br/>
🎨 Choose from many of the included themes, or make your own.<br/>
💽 Mirror workspace data to a directory for integration with Git or Dropbox.<br/>
📜 View response history for each request.<br/>
🔌 Create your own plugins for authentication, template tags, and more!<br/>
🛜 Configure a proxy to access firewall-blocked APIs
- 🪂 Import data from Postman, Insomnia, OpenAPI, Swagger, or Curl.<br/>
- 📤 Send requests via REST, GraphQL, Server Sent Events (SSE), WebSockets, or gRPC.<br/>
- 🔐 Automatically authorize requests with OAuth 2.0, JWT tokens, Basic Auth, and more.<br/>
- 🔎 Filter response bodies using JSONPath or XPath queries.<br/>
- ⛓️ Chain together multiple requests to dynamically reference values.<br/>
- 📂 Organize requests into workspaces and nested folders.<br/>
- 🧮 Use environment variables to easily switch between Prod and Dev.<br/>
- 🏷️ Send dynamic values like UUIDs or timestamps using template tags.<br/>
- 🎨 Choose from many of the included themes, or make your own.<br/>
- 💽 Mirror workspace data to a directory for integration with Git or Dropbox.<br/>
- 📜 View response history for each request.<br/>
- 🔌 Create your own plugins for authentication, template tags, and more!<br/>
- 🛜 Configure a proxy to access firewall-blocked APIs
## Feedback and Bug Reports
All feedback, bug reports, questions, and feature requests should be reported to
[feedback.yaak.app](https://feedback.yaak.app). Issues will be duplicated
in this repository if applicable.
[feedback.yaak.app](https://feedback.yaak.app).
## Community Projects
@@ -34,9 +33,5 @@ in this repository if applicable.
## Contribution Policy
Yaak is open source, but only accepting contributions for bug fixes. See the
[`good first issue`](https://github.com/yaakapp/app/labels/good%20first%20issue) label for
issues that are more approachable for contribution.
To get started, visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your
environment.
Yaak is open source, but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.

141
package-lock.json generated
View File

@@ -2500,9 +2500,9 @@
}
},
"node_modules/@tanstack/history": {
"version": "1.95.0",
"resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.95.0.tgz",
"integrity": "sha512-w1/yWuIBqmG0Z0MPMf1OuOCce7FXyVH4L4dIA4rvpnjIUCH8qRUgloFAVg37nTMUbOmhMsY2NZDxCpKBv+CLJg==",
"version": "1.99.13",
"resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.99.13.tgz",
"integrity": "sha512-JMd7USmnp8zV8BRGIjALqzPxazvKtQ7PGXQC7n39HpbqdsmfV2ePCzieO84IvN+mwsTrXErpbjI4BfKCa+ZNCg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2513,9 +2513,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.62.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.16.tgz",
"integrity": "sha512-9Sgft7Qavcd+sN0V25xVyo0nfmcZXBuODy3FVG7BMWTg1HMLm8wwG5tNlLlmSic1u7l1v786oavn+STiFaPH2g==",
"version": "5.66.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz",
"integrity": "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2534,12 +2534,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.62.16",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.16.tgz",
"integrity": "sha512-XJIZNj65d2IdvU8VBESmrPakfIm6FSdHDzrS1dPrAwmq3ZX+9riMh/ZfbNQHAWnhrgmq7KoXpgZSRyXnqMYT9A==",
"version": "5.66.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.9.tgz",
"integrity": "sha512-NRI02PHJsP5y2gAuWKP+awamTIBFBSKMnO6UVzi03GTclmHHHInH5UzVgzi5tpu4+FmGfsdT7Umqegobtsp23A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.62.16"
"@tanstack/query-core": "5.66.4"
},
"funding": {
"type": "github",
@@ -2568,14 +2568,15 @@
}
},
"node_modules/@tanstack/react-router": {
"version": "1.95.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.95.1.tgz",
"integrity": "sha512-P5x4yNhcdkYsCEoYeGZP8Q9Jlxf0WXJa4G/xvbmM905seZc9FqJqvCSRvX3dWTPOXRABhl4g+8DHqfft0c/AvQ==",
"version": "1.111.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.111.3.tgz",
"integrity": "sha512-OsqAuExa4WF7+BbjENWlb4dHRousxU5jahJHUPyO0gaUcWwzaVloJKi8lTFTd1PWQ8waz5V7BedkV67hd8syUw==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.95.0",
"@tanstack/history": "1.99.13",
"@tanstack/react-store": "^0.7.0",
"jsesc": "^3.0.2",
"@tanstack/router-core": "^1.111.3",
"jsesc": "^3.1.0",
"tiny-invariant": "^1.3.3",
"tiny-warning": "^1.0.3"
},
@@ -2587,8 +2588,8 @@
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
"react": ">=18.0.0 || >=19.0.0",
"react-dom": ">=18.0.0 || >=19.0.0"
}
},
"node_modules/@tanstack/react-store": {
@@ -2610,12 +2611,12 @@
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
"integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==",
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.0.tgz",
"integrity": "sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.11.2"
"@tanstack/virtual-core": "3.13.0"
},
"funding": {
"type": "github",
@@ -2626,6 +2627,23 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.111.3",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.111.3.tgz",
"integrity": "sha512-q+CHuOhTgqHudVKijL89jIdLe5A00RzV8ZMMSi4qiHGnggm4nisF8eSE3dFQaic1+YFk1wR7dfFA2hvkr1hFIA==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.99.13",
"@tanstack/store": "^0.7.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/router-devtools": {
"version": "1.91.3",
"resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.91.3.tgz",
@@ -2730,9 +2748,9 @@
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",
"integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==",
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.0.tgz",
"integrity": "sha512-NBKJP3OIdmZY3COJdWkSonr50FMVIi+aj5ZJ7hI/DTpEKg2RMfo/KvP8A3B/zOSpMgIe52B5E2yn7rryULzA6g==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2754,9 +2772,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.2.tgz",
"integrity": "sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.2.0.tgz",
"integrity": "sha512-R8epOeZl1eJEl603aUMIGb4RXlhPjpgxbGVEaqY+0G5JG9vzV/clNlzTeqc+NLYXVqXcn8mb4c5b9pJIUDEyAg==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -6963,17 +6981,19 @@
}
},
"node_modules/framer-motion": {
"version": "11.11.7",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.11.7.tgz",
"integrity": "sha512-89CgILOXPeG3L7ymOTGrLmf8IiKubYLUN/QkYgQuLvehAHfqgwJbLfCnhuyRI4WTds1TXkUp67A7IJrgRY/j1w==",
"version": "12.4.7",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.7.tgz",
"integrity": "sha512-VhrcbtcAMXfxlrjeHPpWVu2+mkcoR31e02aNSR7OUS/hZAciKa8q6o3YN2mA1h+jjscRsSyKvX6E1CiY/7OLMw==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.4.5",
"motion-utils": "^12.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
@@ -8577,9 +8597,9 @@
}
},
"node_modules/jsesc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
"integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -10008,6 +10028,47 @@
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"license": "BSD-3-Clause"
},
"node_modules/motion": {
"version": "12.4.7",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.4.7.tgz",
"integrity": "sha512-mhegHAbf1r80fr+ytC6OkjKvIUegRNXKLWNPrCN2+GnixlNSPwT03FtKqp9oDny1kNcLWZvwbmEr+JqVryFrcg==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.4.7",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.4.5",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.4.5.tgz",
"integrity": "sha512-Q2xmhuyYug1CGTo0jdsL05EQ4RhIYXlggFS/yPhQQRNzbrhjKQ1tbjThx5Plv68aX31LsUQRq4uIkuDxdO5vRQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.0.0"
}
},
"node_modules/motion-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz",
"integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -15635,10 +15696,10 @@
"@replit/codemirror-vim": "^6.2.1",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.62.16",
"@tanstack/react-router": "^1.95.1",
"@tanstack/react-virtual": "^3.11.2",
"@tauri-apps/api": "^2.0.1",
"@tanstack/react-query": "^5.66.9",
"@tanstack/react-router": "^1.111.3",
"@tanstack/react-virtual": "^3.13.0",
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
@@ -15655,7 +15716,6 @@
"eventemitter3": "^5.0.1",
"focus-trap-react": "^10.2.3",
"format-graphql": "^1.5.0",
"framer-motion": "^11.5.4",
"fuzzbunny": "^1.0.1",
"hexy": "^0.3.5",
"history": "^5.3.0",
@@ -15663,6 +15723,7 @@
"js-md5": "^0.8.3",
"lucide-react": "^0.474.0",
"mime": "^4.0.4",
"motion": "^12.4.7",
"nanoid": "^5.0.9",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@yaakapp/api",
"version": "0.4.1",
"version": "0.5.0",
"main": "lib/index.js",
"typings": "./lib/index.d.ts",
"files": [

View File

@@ -346,7 +346,7 @@ export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, windowContext: WindowContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & EmptyPayload | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & EmptyPayload | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type JsonPrimitive = string | number | boolean | null;
@@ -354,7 +354,7 @@ export type OpenWindowRequest = { url: string,
/**
* Label for the window. If not provided, a random one will be generated.
*/
label: string, title?: string, size?: WindowSize, };
label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

View File

@@ -32,7 +32,10 @@ export interface Context {
};
window: {
openUrl(
args: OpenWindowRequest & { onNavigate?: (args: { url: string }) => void },
args: OpenWindowRequest & {
onNavigate?: (args: { url: string }) => void;
onClose: () => void;
},
): Promise<{ close: () => void }>;
};
httpRequest: {

View File

@@ -3,10 +3,7 @@
"scripts": {
"bootstrap": "npm run build",
"build": "run-p build:*",
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../src-tauri/vendored/plugin-runtime/index.cjs",
"build:worker": "esbuild src/index.worker.ts --bundle --platform=node --outfile=../../src-tauri/vendored/plugin-runtime/index.worker.cjs",
"build:__main": "esbuild src/index.ts --bundle --platform=node --outfile=../../src-tauri/target/debug/vendored/plugin-runtime/index.cjs",
"build:__worker": "esbuild src/index.ts --bundle --platform=node --outfile=../../src-tauri/target/debug/vendored/plugin-runtime/index.worker.cjs"
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../src-tauri/vendored/plugin-runtime/index.cjs"
},
"dependencies": {
"ws": "^8.18.0"

View File

@@ -1,14 +1,19 @@
import type { InternalEvent } from "@yaakapp/api";
import EventEmitter from "node:events";
import type { InternalEvent } from '@yaakapp/api';
export class EventChannel {
emitter: EventEmitter = new EventEmitter();
#listeners = new Set<(event: InternalEvent) => void>();
emit(e: InternalEvent) {
this.emitter.emit("__plugin_event__", e);
for (const l of this.#listeners) {
l(e);
}
}
listen(cb: (e: InternalEvent) => void) {
this.emitter.on("__plugin_event__", cb);
this.#listeners.add(cb);
}
unlisten(cb: (e: InternalEvent) => void) {
this.#listeners.delete(cb);
}
}

View File

@@ -1,56 +1,28 @@
import type { BootRequest, InternalEvent } from '@yaakapp/api';
import path from 'node:path';
import { Worker } from 'node:worker_threads';
import type { EventChannel } from './EventChannel';
import type { PluginWorkerData } from './index.worker';
import { PluginInstance, PluginWorkerData } from './PluginInstance';
export class PluginHandle {
#worker: Worker;
#instance: PluginInstance;
constructor(
readonly pluginRefId: string,
readonly bootRequest: BootRequest,
readonly events: EventChannel,
readonly pluginToAppEvents: EventChannel,
) {
this.#worker = this.#createWorker();
}
sendToWorker(event: InternalEvent) {
this.#worker.postMessage(event);
}
async terminate() {
await this.#worker.terminate();
}
#createWorker(): Worker {
const workerPath = process.env.YAAK_WORKER_PATH ?? path.join(__dirname, 'index.worker.cjs');
const workerData: PluginWorkerData = {
pluginRefId: this.pluginRefId,
bootRequest: this.bootRequest,
};
const worker = new Worker(workerPath, {
workerData,
});
worker.on('message', (e) => this.events.emit(e));
worker.on('error', this.#handleError.bind(this));
worker.on('exit', this.#handleExit.bind(this));
this.#instance = new PluginInstance(workerData, pluginToAppEvents);
console.log('Created plugin worker for ', this.bootRequest.dir);
return worker;
}
async #handleError(err: Error) {
console.error('Plugin errored', this.bootRequest.dir, err);
sendToWorker(event: InternalEvent) {
this.#instance.postMessage(event);
}
async #handleExit(code: number) {
if (code === 0) {
console.log('Plugin exited successfully', this.bootRequest.dir);
} else {
console.log('Plugin exited with status', code, this.bootRequest.dir);
}
terminate() {
this.#instance.terminate();
}
}

View File

@@ -0,0 +1,597 @@
import type {
BootRequest,
Context,
DeleteKeyValueResponse,
FindHttpResponsesResponse,
FormInput,
GetHttpRequestByIdResponse,
GetKeyValueResponse,
HttpAuthenticationAction,
HttpRequestAction,
InternalEvent,
InternalEventPayload,
JsonPrimitive,
PluginDefinition,
PromptTextResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateFunction,
TemplateRenderResponse,
WindowContext,
} from '@yaakapp/api';
import console from 'node:console';
import { readFileSync, type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
// import util from 'node:util';
import { EventChannel } from './EventChannel';
// import { interceptStdout } from './interceptStdout';
import { migrateTemplateFunctionSelectOptions } from './migrations';
export interface PluginWorkerData {
bootRequest: BootRequest;
pluginRefId: string;
}
export class PluginInstance {
#workerData: PluginWorkerData;
#mod: PluginDefinition;
#pkg: { name?: string; version?: string };
#pluginToAppEvents: EventChannel;
#appToPluginEvents: EventChannel;
constructor(workerData: PluginWorkerData, pluginEvents: EventChannel) {
this.#workerData = workerData;
this.#pluginToAppEvents = pluginEvents;
this.#appToPluginEvents = new EventChannel();
// Forward incoming events to onMessage()
this.#appToPluginEvents.listen(async event => {
await this.#onMessage(event);
})
// Reload plugin if the JS or package.json changes
const windowContextNone: WindowContext = { type: 'none' };
const fileChangeCallback = async () => {
this.#importModule();
return this.#sendPayload(windowContextNone, { type: 'reload_response' }, null);
};
if (this.#workerData.bootRequest.watch) {
watchFile(this.#pathMod(), fileChangeCallback);
watchFile(this.#pathPkg(), fileChangeCallback);
}
this.#mod = {};
this.#pkg = JSON.parse(readFileSync(this.#pathPkg(), 'utf8'));
// TODO: Re-implement this now that we're not using workers
// prefixStdout(`[plugin][${this.#pkg.name}] %s`);
this.#importModule();
}
postMessage(event: InternalEvent) {
this.#appToPluginEvents.emit(event);
}
terminate() {
this.#unimportModule();
}
async #onMessage(event: InternalEvent) {
const ctx = this.#newCtx(event);
const { windowContext, payload, id: replyId } = event;
try {
if (payload.type === 'boot_request') {
// console.log('Plugin initialized', pkg.name, { capabilities, enableWatch });
const payload: InternalEventPayload = {
type: 'boot_response',
name: this.#pkg.name ?? 'unknown',
version: this.#pkg.version ?? '0.0.1',
};
this.#sendPayload(windowContext, payload, replyId);
return;
}
if (payload.type === 'terminate_request') {
const payload: InternalEventPayload = {
type: 'terminate_response',
};
this.#sendPayload(windowContext, payload, replyId);
return;
}
if (
payload.type === 'import_request' &&
typeof this.#mod?.importer?.onImport === 'function'
) {
const reply = await this.#mod.importer.onImport(ctx, {
text: payload.content,
});
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: 'import_response',
// deno-lint-ignore no-explicit-any
resources: reply.resources as any,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
} else {
// Continue, to send back an empty reply
}
}
if (payload.type === 'filter_request' && typeof this.#mod?.filter?.onFilter === 'function') {
const reply = await this.#mod.filter.onFilter(ctx, {
filter: payload.filter,
payload: payload.content,
mimeType: payload.type,
});
const replyPayload: InternalEventPayload = {
type: 'filter_response',
content: reply.filtered,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (
payload.type === 'get_http_request_actions_request' &&
Array.isArray(this.#mod?.httpRequestActions)
) {
const reply: HttpRequestAction[] = this.#mod.httpRequestActions.map((a) => ({
...a,
// Add everything except onSelect
onSelect: undefined,
}));
const replyPayload: InternalEventPayload = {
type: 'get_http_request_actions_response',
pluginRefId: this.#workerData.pluginRefId,
actions: reply,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (
payload.type === 'get_template_functions_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
const reply: TemplateFunction[] = this.#mod.templateFunctions.map((templateFunction) => {
return {
...migrateTemplateFunctionSelectOptions(templateFunction),
// Add everything except render
onRender: undefined,
};
});
const replyPayload: InternalEventPayload = {
type: 'get_template_functions_response',
pluginRefId: this.#workerData.pluginRefId,
functions: reply,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'get_http_authentication_summary_request' && this.#mod?.authentication) {
const { name, shortLabel, label } = this.#mod.authentication;
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_summary_response',
name,
label,
shortLabel,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'get_http_authentication_config_request' && this.#mod?.authentication) {
const { args, actions } = this.#mod.authentication;
const resolvedArgs: FormInput[] = [];
for (let i = 0; i < args.length; i++) {
let v = args[i];
if ('dynamic' in v) {
const dynamicAttrs = await v.dynamic(ctx, payload);
const { dynamic, ...other } = v;
resolvedArgs.push({ ...other, ...dynamicAttrs } as FormInput);
} else {
resolvedArgs.push(v);
}
}
const resolvedActions: HttpAuthenticationAction[] = [];
for (const { onSelect, ...action } of actions ?? []) {
resolvedActions.push(action);
}
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_config_response',
args: resolvedArgs,
actions: resolvedActions,
pluginRefId: this.#workerData.pluginRefId,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) {
const auth = this.#mod.authentication;
if (typeof auth?.onApply === 'function') {
applyFormInputDefaults(auth.args, payload.values);
const result = await auth.onApply(ctx, payload);
this.#sendPayload(
windowContext,
{
type: 'call_http_authentication_response',
setHeaders: result.setHeaders,
},
replyId,
);
return;
}
}
if (
payload.type === 'call_http_authentication_action_request' &&
this.#mod.authentication != null
) {
const action = this.#mod.authentication.actions?.[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(windowContext, replyId);
return;
}
}
if (
payload.type === 'call_http_request_action_request' &&
Array.isArray(this.#mod.httpRequestActions)
) {
const action = this.#mod.httpRequestActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(windowContext, replyId);
return;
}
}
if (
payload.type === 'call_template_function_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
const action = this.#mod.templateFunctions.find((a) => a.name === payload.name);
if (typeof action?.onRender === 'function') {
applyFormInputDefaults(action.args, payload.args.values);
const result = await action.onRender(ctx, payload.args);
this.#sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: result ?? null,
},
replyId,
);
return;
}
}
if (payload.type === 'reload_request') {
this.#importModule();
}
} catch (err) {
console.log('Plugin call threw exception', payload.type, err);
this.#sendPayload(
windowContext,
{
type: 'error_response',
error: `${err}`,
},
replyId,
);
return;
}
// No matches, so send back an empty response so the caller doesn't block forever
this.#sendEmpty(windowContext, replyId);
}
#pathMod() {
return path.posix.join(this.#workerData.bootRequest.dir, 'build', 'index.js');
}
#pathPkg() {
return path.join(this.#workerData.bootRequest.dir, 'package.json');
}
#unimportModule() {
const id = require.resolve(this.#pathMod());
delete require.cache[id];
}
#importModule() {
const id = require.resolve(this.#pathMod());
delete require.cache[id];
this.#mod = require(id).plugin;
}
#buildEventToSend(
windowContext: WindowContext,
payload: InternalEventPayload,
replyId: string | null = null,
): InternalEvent {
return {
pluginRefId: this.#workerData.pluginRefId,
pluginName: path.basename(this.#workerData.bootRequest.dir),
id: genId(),
replyId,
payload,
windowContext,
};
}
#sendPayload(
windowContext: WindowContext,
payload: InternalEventPayload,
replyId: string | null,
): string {
const event = this.#buildEventToSend(windowContext, payload, replyId);
this.#sendEvent(event);
return event.id;
}
#sendEvent(event: InternalEvent) {
// if (event.payload.type !== 'empty_response') {
// console.log('Sending event to app', this.#pkg.name, event.id, event.payload.type);
// }
this.#pluginToAppEvents.emit(event);
}
#sendEmpty(windowContext: WindowContext, replyId: string | null = null): string {
return this.#sendPayload(windowContext, { type: 'empty_response' }, replyId);
}
#sendAndWaitForReply<T extends Omit<InternalEventPayload, 'type'>>(
windowContext: WindowContext,
payload: InternalEventPayload,
): Promise<T> {
// 1. Build event to send
const eventToSend = this.#buildEventToSend(windowContext, payload, null);
// 2. Spawn listener in background
const promise = new Promise<T>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
const { type: _, ...payload } = event.payload;
resolve(payload as T);
}
};
this.#appToPluginEvents.listen(cb);
});
// 3. Send the event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
// 4. Return the listener promise
return promise as unknown as Promise<T>;
}
#sendAndListenForEvents(
windowContext: WindowContext,
payload: InternalEventPayload,
onEvent: (event: InternalEventPayload) => void,
): void {
// 1. Build event to send
const eventToSend = this.#buildEventToSend(windowContext, payload, null);
// 2. Listen for replies in the background
this.#appToPluginEvents.listen((event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
onEvent(event.payload);
}
});
// 3. Send the event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
}
#newCtx(event: InternalEvent): Context {
return {
clipboard: {
copyText: async (text) => {
await this.#sendAndWaitForReply(event.windowContext, {
type: 'copy_text_request',
text,
});
},
},
toast: {
show: async (args) => {
await this.#sendAndWaitForReply(event.windowContext, {
type: 'show_toast_request',
...args,
});
},
},
window: {
openUrl: async ({ onNavigate, onClose, ...args }) => {
args.label = args.label || `${Math.random()}`;
const payload: InternalEventPayload = { type: 'open_window_request', ...args };
const onEvent = (event: InternalEventPayload) => {
if (event.type === 'window_navigate_event') {
onNavigate?.(event);
} else if (event.type === 'window_close_event') {
onClose?.();
}
};
this.#sendAndListenForEvents(event.windowContext, payload, onEvent);
return {
close: () => {
const closePayload: InternalEventPayload = {
type: 'close_window_request',
label: args.label,
};
this.#sendPayload(event.windowContext, closePayload, null);
},
};
},
},
prompt: {
text: async (args) => {
const reply: PromptTextResponse = await this.#sendAndWaitForReply(event.windowContext, {
type: 'prompt_text_request',
...args,
});
return reply.value;
},
},
httpResponse: {
find: async (args) => {
const payload = {
type: 'find_http_responses_request',
...args,
} as const;
const { httpResponses } = await this.#sendAndWaitForReply<FindHttpResponsesResponse>(
event.windowContext,
payload,
);
return httpResponses;
},
},
httpRequest: {
getById: async (args) => {
const payload = {
type: 'get_http_request_by_id_request',
...args,
} as const;
const { httpRequest } = await this.#sendAndWaitForReply<GetHttpRequestByIdResponse>(
event.windowContext,
payload,
);
return httpRequest;
},
send: async (args) => {
const payload = {
type: 'send_http_request_request',
...args,
} as const;
const { httpResponse } = await this.#sendAndWaitForReply<SendHttpRequestResponse>(
event.windowContext,
payload,
);
return httpResponse;
},
render: async (args) => {
const payload = {
type: 'render_http_request_request',
...args,
} as const;
const { httpRequest } = await this.#sendAndWaitForReply<RenderHttpRequestResponse>(
event.windowContext,
payload,
);
return httpRequest;
},
},
templates: {
/**
* Invoke Yaak's template engine to render a value. If the value is a nested type
* (eg. object), it will be recursively rendered.
*/
render: async (args) => {
const payload = { type: 'template_render_request', ...args } as const;
const result = await this.#sendAndWaitForReply<TemplateRenderResponse>(
event.windowContext,
payload,
);
return result.data;
},
},
store: {
get: async <T>(key: string) => {
const payload = { type: 'get_key_value_request', key } as const;
const result = await this.#sendAndWaitForReply<GetKeyValueResponse>(
event.windowContext,
payload,
);
return result.value ? (JSON.parse(result.value) as T) : undefined;
},
set: async <T>(key: string, value: T) => {
const valueStr = JSON.stringify(value);
const payload: InternalEventPayload = {
type: 'set_key_value_request',
key,
value: valueStr,
};
await this.#sendAndWaitForReply<GetKeyValueResponse>(event.windowContext, payload);
},
delete: async (key: string) => {
const payload = { type: 'delete_key_value_request', key } as const;
const result = await this.#sendAndWaitForReply<DeleteKeyValueResponse>(
event.windowContext,
payload,
);
return result.deleted;
},
},
};
}
}
function genId(len = 5): string {
const alphabet = '01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = '';
for (let i = 0; i < len; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return id;
}
/** Recursively apply form input defaults to a set of values */
function applyFormInputDefaults(
inputs: FormInput[],
values: { [p: string]: JsonPrimitive | undefined },
) {
for (const input of inputs) {
if ('inputs' in input) {
applyFormInputDefaults(input.inputs ?? [], values);
} else if ('defaultValue' in input && values[input.name] === undefined) {
values[input.name] = input.defaultValue;
}
}
}
const watchedFiles: Record<string, Stats> = {};
/**
* Watch a file and trigger callback on change.
*
* We also track the stat for each file because fs.watch() will
* trigger a "change" event when the access date changes
*/
function watchFile(filepath: string, cb: (filepath: string) => void) {
watch(filepath, () => {
const stat = statSync(filepath);
if (stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {
cb(filepath);
}
watchedFiles[filepath] = stat;
});
}
// function prefixStdout(s: string) {
// if (!s.includes('%s')) {
// throw new Error('Console prefix must contain a "%s" replacer');
// }
// interceptStdout((text: string) => {
// const lines = text.split(/\n/);
// let newText = '';
// for (let i = 0; i < lines.length; i++) {
// if (lines[i] == '') continue;
// newText += util.format(s, lines[i]) + '\n';
// }
// return newText.trimEnd();
// });
// }

View File

@@ -8,7 +8,7 @@ if (!port) {
throw new Error('Plugin runtime missing PORT')
}
const events = new EventChannel();
const pluginToAppEvents = new EventChannel();
const plugins: Record<string, PluginHandle> = {};
const ws = new WebSocket(`ws://localhost:${port}`);
@@ -25,7 +25,7 @@ ws.on('error', (err: any) => console.error('Plugin runtime websocket error', err
ws.on('close', (code: number) => console.log('Plugin runtime websocket closed', code));
// Listen for incoming events from plugins
events.listen((e) => {
pluginToAppEvents.listen((e) => {
const eventStr = JSON.stringify(e);
ws.send(eventStr);
});
@@ -34,7 +34,7 @@ async function handleIncoming(msg: string) {
const pluginEvent: InternalEvent = JSON.parse(msg);
// Handle special event to bootstrap plugin
if (pluginEvent.payload.type === 'boot_request') {
const plugin = new PluginHandle(pluginEvent.pluginRefId, pluginEvent.payload, events);
const plugin = new PluginHandle(pluginEvent.pluginRefId, pluginEvent.payload, pluginToAppEvents);
plugins[pluginEvent.pluginRefId] = plugin;
}
@@ -46,7 +46,7 @@ async function handleIncoming(msg: string) {
}
if (pluginEvent.payload.type === 'terminate_request') {
await plugin.terminate();
plugin.terminate();
console.log('Terminated plugin worker', pluginEvent.pluginRefId);
delete plugins[pluginEvent.pluginRefId];
}

View File

@@ -1,568 +0,0 @@
// OAuth 2.0 spec -> https://datatracker.ietf.org/doc/html/rfc6749
import type {
BootRequest,
Context,
DeleteKeyValueResponse,
FindHttpResponsesResponse,
FormInput,
GetHttpRequestByIdResponse,
GetKeyValueResponse,
HttpAuthenticationAction,
HttpRequestAction,
InternalEvent,
InternalEventPayload,
JsonPrimitive,
PluginDefinition,
PromptTextResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateFunction,
TemplateRenderResponse,
WindowContext,
} from '@yaakapp/api';
import * as console from 'node:console';
import type { Stats } from 'node:fs';
import { readFileSync, statSync, watch } from 'node:fs';
import path from 'node:path';
import * as util from 'node:util';
import { parentPort as nullableParentPort, workerData } from 'node:worker_threads';
import { interceptStdout } from './interceptStdout';
import { migrateTemplateFunctionSelectOptions } from './migrations';
if (nullableParentPort == null) {
throw new Error('Worker does not have access to parentPort');
}
const parentPort = nullableParentPort;
export interface PluginWorkerData {
bootRequest: BootRequest;
pluginRefId: string;
}
function initialize(workerData: PluginWorkerData) {
const {
bootRequest: { dir: pluginDir, watch: enableWatch },
pluginRefId,
}: PluginWorkerData = workerData;
const pathPkg = path.join(pluginDir, 'package.json');
const pathMod = path.posix.join(pluginDir, 'build', 'index.js');
const pkg = JSON.parse(readFileSync(pathPkg, 'utf8'));
prefixStdout(`[plugin][${pkg.name}] %s`);
function buildEventToSend(
windowContext: WindowContext,
payload: InternalEventPayload,
replyId: string | null = null,
): InternalEvent {
return {
pluginRefId,
pluginName: path.basename(pluginDir),
id: genId(),
replyId,
payload,
windowContext,
};
}
function sendEmpty(windowContext: WindowContext, replyId: string | null = null): string {
return sendPayload(windowContext, { type: 'empty_response' }, replyId);
}
function sendPayload(
windowContext: WindowContext,
payload: InternalEventPayload,
replyId: string | null,
): string {
const event = buildEventToSend(windowContext, payload, replyId);
sendEvent(event);
return event.id;
}
function sendEvent(event: InternalEvent) {
if (event.payload.type !== 'empty_response') {
console.log('Sending event to app', event.id, event.payload.type);
}
parentPort.postMessage(event);
}
function sendAndWaitForReply<T extends Omit<InternalEventPayload, 'type'>>(
windowContext: WindowContext,
payload: InternalEventPayload,
): Promise<T> {
// 1. Build event to send
const eventToSend = buildEventToSend(windowContext, payload, null);
// 2. Spawn listener in background
const promise = new Promise<T>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
parentPort.off('message', cb); // Unlisten, now that we're done
const { type: _, ...payload } = event.payload;
resolve(payload as T);
}
};
parentPort.on('message', cb);
});
// 3. Send the event after we start listening (to prevent race)
sendEvent(eventToSend);
// 4. Return the listener promise
return promise as unknown as Promise<T>;
}
function sendAndListenForEvents(
windowContext: WindowContext,
payload: InternalEventPayload,
onEvent: (event: InternalEventPayload) => void,
): void {
// 1. Build event to send
const eventToSend = buildEventToSend(windowContext, payload, null);
// 2. Listen for replies in the background
parentPort.on('message', (event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
onEvent(event.payload);
}
});
// 3. Send the event after we start listening (to prevent race)
sendEvent(eventToSend);
}
// Reload plugin if the JS or package.json changes
const windowContextNone: WindowContext = { type: 'none' };
const fileChangeCallback = async () => {
importModule();
return sendPayload(windowContextNone, { type: 'reload_response' }, null);
};
if (enableWatch) {
watchFile(pathMod, fileChangeCallback);
watchFile(pathPkg, fileChangeCallback);
}
const newCtx = (event: InternalEvent): Context => ({
clipboard: {
async copyText(text) {
await sendAndWaitForReply(event.windowContext, {
type: 'copy_text_request',
text,
});
},
},
toast: {
async show(args) {
await sendAndWaitForReply(event.windowContext, {
type: 'show_toast_request',
...args,
});
},
},
window: {
async openUrl({ onNavigate, ...args }) {
args.label = args.label || `${Math.random()}`;
const payload: InternalEventPayload = { type: 'open_window_request', ...args };
const onEvent = (event: InternalEventPayload) => {
if (event.type === 'window_navigate_event') {
onNavigate?.(event);
}
};
sendAndListenForEvents(event.windowContext, payload, onEvent);
return {
close: () => {
const closePayload: InternalEventPayload = {
type: 'close_window_request',
label: args.label,
};
sendPayload(event.windowContext, closePayload, null);
},
};
},
},
prompt: {
async text(args) {
const reply: PromptTextResponse = await sendAndWaitForReply(event.windowContext, {
type: 'prompt_text_request',
...args,
});
return reply.value;
},
},
httpResponse: {
async find(args) {
const payload = {
type: 'find_http_responses_request',
...args,
} as const;
const { httpResponses } = await sendAndWaitForReply<FindHttpResponsesResponse>(
event.windowContext,
payload,
);
return httpResponses;
},
},
httpRequest: {
async getById(args) {
const payload = {
type: 'get_http_request_by_id_request',
...args,
} as const;
const { httpRequest } = await sendAndWaitForReply<GetHttpRequestByIdResponse>(
event.windowContext,
payload,
);
return httpRequest;
},
async send(args) {
const payload = {
type: 'send_http_request_request',
...args,
} as const;
const { httpResponse } = await sendAndWaitForReply<SendHttpRequestResponse>(
event.windowContext,
payload,
);
return httpResponse;
},
async render(args) {
const payload = {
type: 'render_http_request_request',
...args,
} as const;
const { httpRequest } = await sendAndWaitForReply<RenderHttpRequestResponse>(
event.windowContext,
payload,
);
return httpRequest;
},
},
templates: {
/**
* Invoke Yaak's template engine to render a value. If the value is a nested type
* (eg. object), it will be recursively rendered.
*/
async render(args) {
const payload = { type: 'template_render_request', ...args } as const;
const result = await sendAndWaitForReply<TemplateRenderResponse>(
event.windowContext,
payload,
);
return result.data;
},
},
store: {
async get<T>(key: string) {
const payload = { type: 'get_key_value_request', key } as const;
const result = await sendAndWaitForReply<GetKeyValueResponse>(event.windowContext, payload);
return result.value ? (JSON.parse(result.value) as T) : undefined;
},
async set<T>(key: string, value: T) {
const valueStr = JSON.stringify(value);
const payload: InternalEventPayload = {
type: 'set_key_value_request',
key,
value: valueStr,
};
await sendAndWaitForReply<GetKeyValueResponse>(event.windowContext, payload);
},
async delete(key: string) {
const payload = { type: 'delete_key_value_request', key } as const;
const result = await sendAndWaitForReply<DeleteKeyValueResponse>(
event.windowContext,
payload,
);
return result.deleted;
},
},
});
let plug: PluginDefinition | null = null;
function importModule() {
const id = require.resolve(pathMod);
delete require.cache[id];
plug = require(id).plugin;
}
importModule();
// Message comes into the plugin to be processed
parentPort.on('message', async (event: InternalEvent) => {
const ctx = newCtx(event);
const { windowContext, payload, id: replyId } = event;
try {
if (payload.type === 'boot_request') {
// console.log('Plugin initialized', pkg.name, { capabilities, enableWatch });
const payload: InternalEventPayload = {
type: 'boot_response',
name: pkg.name,
version: pkg.version,
};
sendPayload(windowContext, payload, replyId);
return;
}
if (payload.type === 'terminate_request') {
const payload: InternalEventPayload = {
type: 'terminate_response',
};
sendPayload(windowContext, payload, replyId);
return;
}
if (payload.type === 'import_request' && typeof plug?.importer?.onImport === 'function') {
const reply = await plug.importer.onImport(ctx, {
text: payload.content,
});
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: 'import_response',
// deno-lint-ignore no-explicit-any
resources: reply.resources as any,
};
sendPayload(windowContext, replyPayload, replyId);
return;
} else {
// Continue, to send back an empty reply
}
}
if (payload.type === 'filter_request' && typeof plug?.filter?.onFilter === 'function') {
const reply = await plug.filter.onFilter(ctx, {
filter: payload.filter,
payload: payload.content,
mimeType: payload.type,
});
const replyPayload: InternalEventPayload = {
type: 'filter_response',
content: reply.filtered,
};
sendPayload(windowContext, replyPayload, replyId);
return;
}
if (
payload.type === 'get_http_request_actions_request' &&
Array.isArray(plug?.httpRequestActions)
) {
const reply: HttpRequestAction[] = plug.httpRequestActions.map((a) => ({
...a,
// Add everything except onSelect
onSelect: undefined,
}));
const replyPayload: InternalEventPayload = {
type: 'get_http_request_actions_response',
pluginRefId,
actions: reply,
};
sendPayload(windowContext, replyPayload, replyId);
return;
}
if (
payload.type === 'get_template_functions_request' &&
Array.isArray(plug?.templateFunctions)
) {
const reply: TemplateFunction[] = plug.templateFunctions.map((templateFunction) => {
return {
...migrateTemplateFunctionSelectOptions(templateFunction),
// Add everything except render
onRender: undefined,
};
});
const replyPayload: InternalEventPayload = {
type: 'get_template_functions_response',
pluginRefId,
functions: reply,
};
sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'get_http_authentication_summary_request' && plug?.authentication) {
const { name, shortLabel, label } = plug.authentication;
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_summary_response',
name,
label,
shortLabel,
};
sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'get_http_authentication_config_request' && plug?.authentication) {
const { args, actions } = plug.authentication;
const resolvedArgs: FormInput[] = [];
for (let i = 0; i < args.length; i++) {
let v = args[i];
if ('dynamic' in v) {
const dynamicAttrs = await v.dynamic(ctx, payload);
const { dynamic, ...other } = v;
resolvedArgs.push({ ...other, ...dynamicAttrs } as FormInput);
} else {
resolvedArgs.push(v);
}
}
const resolvedActions: HttpAuthenticationAction[] = [];
for (const { onSelect, ...action } of actions ?? []) {
resolvedActions.push(action);
}
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_config_response',
args: resolvedArgs,
actions: resolvedActions,
pluginRefId,
};
sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'call_http_authentication_request' && plug?.authentication) {
const auth = plug.authentication;
if (typeof auth?.onApply === 'function') {
applyFormInputDefaults(auth.args, payload.values);
const result = await auth.onApply(ctx, payload);
sendPayload(
windowContext,
{
type: 'call_http_authentication_response',
setHeaders: result.setHeaders,
},
replyId,
);
return;
}
}
if (
payload.type === 'call_http_authentication_action_request' &&
plug?.authentication != null
) {
const action = plug.authentication.actions?.[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
sendEmpty(windowContext, replyId);
return;
}
}
if (
payload.type === 'call_http_request_action_request' &&
Array.isArray(plug?.httpRequestActions)
) {
const action = plug.httpRequestActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
sendEmpty(windowContext, replyId);
return;
}
}
if (
payload.type === 'call_template_function_request' &&
Array.isArray(plug?.templateFunctions)
) {
const action = plug.templateFunctions.find((a) => a.name === payload.name);
if (typeof action?.onRender === 'function') {
applyFormInputDefaults(action.args, payload.args.values);
const result = await action.onRender(ctx, payload.args);
sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: result ?? null,
},
replyId,
);
return;
}
}
if (payload.type === 'reload_request') {
importModule();
}
} catch (err) {
console.log('Plugin call threw exception', payload.type, err);
sendPayload(
windowContext,
{
type: 'error_response',
error: `${err}`,
},
replyId,
);
return;
}
// No matches, so send back an empty response so the caller doesn't block forever
sendEmpty(windowContext, replyId);
});
}
initialize(workerData);
function genId(len = 5): string {
const alphabet = '01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = '';
for (let i = 0; i < len; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return id;
}
function prefixStdout(s: string) {
if (!s.includes('%s')) {
throw new Error('Console prefix must contain a "%s" replacer');
}
interceptStdout((text: string) => {
const lines = text.split(/\n/);
let newText = '';
for (let i = 0; i < lines.length; i++) {
if (lines[i] == '') continue;
newText += util.format(s, lines[i]) + '\n';
}
return newText.trimEnd();
});
}
const watchedFiles: Record<string, Stats> = {};
/**
* Watch a file and trigger callback on change.
*
* We also track the stat for each file because fs.watch() will
* trigger a "change" event when the access date changes
*/
function watchFile(filepath: string, cb: (filepath: string) => void) {
watch(filepath, () => {
const stat = statSync(filepath);
if (stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {
cb(filepath);
}
watchedFiles[filepath] = stat;
});
}
/** Recursively apply form input defaults to a set of values */
function applyFormInputDefaults(
inputs: FormInput[],
values: { [p: string]: JsonPrimitive | undefined },
) {
for (const input of inputs) {
if ('inputs' in input) {
applyFormInputDefaults(input.inputs ?? [], values);
} else if ('defaultValue' in input && values[input.name] === undefined) {
values[input.name] = input.defaultValue;
}
}
}

98
src-tauri/Cargo.lock generated
View File

@@ -514,6 +514,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7"
dependencies = [
"objc-sys",
]
[[package]]
name = "block2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58aa60e59d8dbfcc36138f5f18be5f24394d33b38b24f7fd0b1caa33095f22f"
dependencies = [
"block-sys",
"objc2",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -2420,6 +2439,16 @@ dependencies = [
"png",
]
[[package]]
name = "icrate"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb69199826926eb864697bddd27f73d9fddcffc004f5733131e15b465e30642"
dependencies = [
"block2 0.4.0",
"objc2",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -3292,7 +3321,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"libc",
"objc2",
"objc2-core-data",
@@ -3308,7 +3337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-core-location",
"objc2-foundation",
@@ -3320,7 +3349,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
]
@@ -3332,7 +3361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
]
@@ -3343,7 +3372,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
"objc2-metal",
@@ -3355,7 +3384,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-contacts",
"objc2-foundation",
@@ -3374,7 +3403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"dispatch",
"libc",
"objc2",
@@ -3386,7 +3415,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
@@ -3399,11 +3428,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6788b04a18ea31e3dc3ab256b8546639e5bbae07c1a0dc4ea8615252bc6aee9a"
dependencies = [
"bitflags 2.6.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -3411,7 +3452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
"objc2-metal",
@@ -3434,7 +3475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
@@ -3454,7 +3495,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
]
@@ -3466,7 +3507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-core-location",
"objc2-foundation",
@@ -3479,7 +3520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
@@ -3513,9 +3554,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.66"
version = "0.10.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1"
checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
@@ -3611,6 +3652,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "osakit"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35366a452fce3f8947eb2f33226a133aaf0cacedef2af67ade348d58be7f85d0"
dependencies = [
"icrate",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 1.0.63",
]
[[package]]
name = "pad"
version = "0.1.6"
@@ -4513,7 +4568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8af382a047821a08aa6bfc09ab0d80ff48d45d8726f7cd8e44891f7cb4a4278e"
dependencies = [
"ashpd",
"block2",
"block2 0.5.1",
"glib-sys",
"gobject-sys",
"gtk-sys",
@@ -5980,17 +6035,18 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad3de2b9203bb00b9765e637a9878aaace34df40ae484878b8cea7a5bd5f9188"
checksum = "ebf3da08c36fb03c98c76e5563d4e74d9a590df0f40978cbe07f39cb52833f7c"
dependencies = [
"base64 0.22.1",
"dirs 5.0.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"infer",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"semver",
@@ -7544,7 +7600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e644bf458e27b11b0ecafc9e5633d1304fdae82baca1d42185669752fe6ca4f"
dependencies = [
"base64 0.22.1",
"block2",
"block2 0.5.1",
"cookie",
"crossbeam-channel",
"dpi",

View File

@@ -67,7 +67,7 @@ tauri-plugin-opener = "2.2.5"
tauri-plugin-os = "2.2.0"
tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = "2.2.1"
tauri-plugin-updater = "2.4.0"
tauri-plugin-updater = "2.5.0"
tauri-plugin-window-state = "2.2.1"
tokio = { version = "1.43.0", features = ["sync"] }
tokio-stream = "0.1.17"

View File

@@ -1,5 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AnalyticsAction = "cancel" | "click" | "commit" | "create" | "delete" | "delete_many" | "duplicate" | "error" | "export" | "hide" | "import" | "launch" | "launch_first" | "launch_update" | "send" | "show" | "toggle" | "update" | "upsert";
export type AnalyticsResource = "app" | "appearance" | "button" | "checkbox" | "cookie_jar" | "dialog" | "environment" | "folder" | "grpc_connection" | "grpc_event" | "grpc_request" | "http_request" | "http_response" | "key_value" | "link" | "mutation" | "plugin" | "select" | "setting" | "sidebar" | "tab" | "theme" | "websocket_connection" | "websocket_event" | "websocket_request" | "workspace";

View File

@@ -1,247 +0,0 @@
use std::fmt::Display;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tauri::{Manager, Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_models::queries::{
generate_id, get_key_value_int, get_key_value_string, get_or_create_settings,
set_key_value_int, set_key_value_string, UpdateSource,
};
use crate::is_dev;
const NAMESPACE: &str = "analytics";
const NUM_LAUNCHES_KEY: &str = "num_launches";
// serializable
#[derive(Serialize, Deserialize, Debug, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "analytics.ts")]
pub enum AnalyticsResource {
App,
Appearance,
Button,
Checkbox,
CookieJar,
Dialog,
Environment,
Folder,
GrpcConnection,
GrpcEvent,
GrpcRequest,
HttpRequest,
HttpResponse,
KeyValue,
Link,
Mutation,
Plugin,
Select,
Setting,
Sidebar,
Tab,
Theme,
WebsocketConnection,
WebsocketEvent,
WebsocketRequest,
Workspace,
}
impl AnalyticsResource {
pub fn from_str(s: &str) -> serde_json::Result<AnalyticsResource> {
serde_json::from_str(format!("\"{s}\"").as_str())
}
}
impl Display for AnalyticsResource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", ""))
}
}
#[derive(Serialize, Deserialize, Debug, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "analytics.ts")]
pub enum AnalyticsAction {
Cancel,
Click,
Commit,
Create,
Delete,
DeleteMany,
Duplicate,
Error,
Export,
Hide,
Import,
Launch,
LaunchFirst,
LaunchUpdate,
Send,
Show,
Toggle,
Update,
Upsert,
}
impl AnalyticsAction {
pub fn from_str(s: &str) -> serde_json::Result<AnalyticsAction> {
serde_json::from_str(format!("\"{s}\"").as_str())
}
}
impl Display for AnalyticsAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", ""))
}
}
#[derive(Default, Debug)]
pub struct LaunchEventInfo {
pub current_version: String,
pub previous_version: String,
pub launched_after_update: bool,
pub num_launches: i32,
}
pub async fn track_launch_event<R: Runtime>(w: &WebviewWindow<R>) -> LaunchEventInfo {
let last_tracked_version_key = "last_tracked_version";
let mut info = LaunchEventInfo::default();
info.num_launches = get_num_launches(w).await + 1;
info.previous_version = get_key_value_string(w, NAMESPACE, last_tracked_version_key, "").await;
info.current_version = w.package_info().version.to_string();
if info.previous_version.is_empty() {
track_event(w, AnalyticsResource::App, AnalyticsAction::LaunchFirst, None).await;
} else {
info.launched_after_update = info.current_version != info.previous_version;
if info.launched_after_update {
track_event(
w,
AnalyticsResource::App,
AnalyticsAction::LaunchUpdate,
Some(json!({ NUM_LAUNCHES_KEY: info.num_launches })),
)
.await;
}
};
// Track a launch event in all cases
track_event(
w,
AnalyticsResource::App,
AnalyticsAction::Launch,
Some(json!({ NUM_LAUNCHES_KEY: info.num_launches })),
)
.await;
// Update key values
set_key_value_string(
w,
NAMESPACE,
last_tracked_version_key,
info.current_version.as_str(),
&UpdateSource::Background,
)
.await;
set_key_value_int(w, NAMESPACE, NUM_LAUNCHES_KEY, info.num_launches, &UpdateSource::Background)
.await;
info
}
pub async fn track_event<R: Runtime>(
w: &WebviewWindow<R>,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<Value>,
) {
let id = get_id(w).await;
let event = format!("{}.{}", resource, action);
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = w.app_handle().package_info();
let tz = datetime::sys_timezone().unwrap_or("unknown".to_string());
let site = match is_dev() {
true => "site_TkHWjoXwZPq3HfhERb",
false => "site_zOK0d7jeBy2TLxFCnZ",
};
let base_url = match is_dev() {
true => "http://localhost:7194",
false => "https://t.yaak.app",
};
let params = vec![
("u", id),
("e", event.clone()),
("a", attributes_json.clone()),
("id", site.to_string()),
("v", info.version.clone().to_string()),
("os", get_os().to_string()),
("tz", tz),
("xy", get_window_size(w)),
];
let req =
reqwest::Client::builder().build().unwrap().get(format!("{base_url}/t/e")).query(&params);
let settings = get_or_create_settings(w).await;
if !settings.telemetry {
info!("Track event (disabled): {}", event);
return;
}
// Disable analytics actual sending in dev
if is_dev() {
debug!("Track event: {} {}", event, attributes_json);
return;
}
if let Err(e) = req.send().await {
info!("Error sending analytics event: {}", e);
}
}
pub fn get_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
fn get_window_size<R: Runtime>(w: &WebviewWindow<R>) -> String {
let current_monitor = match w.current_monitor() {
Ok(Some(m)) => m,
_ => return "unknown".to_string(),
};
let scale_factor = current_monitor.scale_factor();
let size = current_monitor.size();
let width: f64 = size.width as f64 / scale_factor;
let height: f64 = size.height as f64 / scale_factor;
format!("{}x{}", (width / 100.0).round() * 100.0, (height / 100.0).round() * 100.0)
}
async fn get_id<R: Runtime>(w: &WebviewWindow<R>) -> String {
let id = get_key_value_string(w, "analytics", "id", "").await;
if id.is_empty() {
let new_id = generate_id();
set_key_value_string(w, "analytics", "id", new_id.as_str(), &UpdateSource::Background)
.await;
new_id
} else {
id
}
}
pub async fn get_num_launches<R: Runtime>(w: &WebviewWindow<R>) -> i32 {
get_key_value_int(w, NAMESPACE, NUM_LAUNCHES_KEY, 0).await
}

64
src-tauri/src/history.rs Normal file
View File

@@ -0,0 +1,64 @@
use tauri::{Manager, Runtime, WebviewWindow};
use yaak_models::queries::{
get_key_value_int, get_key_value_string,
set_key_value_int, set_key_value_string, UpdateSource,
};
const NAMESPACE: &str = "analytics";
const NUM_LAUNCHES_KEY: &str = "num_launches";
#[derive(Default, Debug)]
pub struct LaunchEventInfo {
pub current_version: String,
pub previous_version: String,
pub launched_after_update: bool,
pub num_launches: i32,
}
pub async fn store_launch_history<R: Runtime>(w: &WebviewWindow<R>) -> LaunchEventInfo {
let last_tracked_version_key = "last_tracked_version";
let mut info = LaunchEventInfo::default();
info.num_launches = get_num_launches(w).await + 1;
info.previous_version = get_key_value_string(w, NAMESPACE, last_tracked_version_key, "").await;
info.current_version = w.package_info().version.to_string();
if info.previous_version.is_empty() {
} else {
info.launched_after_update = info.current_version != info.previous_version;
};
// Update key values
set_key_value_string(
w,
NAMESPACE,
last_tracked_version_key,
info.current_version.as_str(),
&UpdateSource::Background,
)
.await;
set_key_value_int(w, NAMESPACE, NUM_LAUNCHES_KEY, info.num_launches, &UpdateSource::Background)
.await;
info
}
pub fn get_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
pub async fn get_num_launches<R: Runtime>(w: &WebviewWindow<R>) -> i32 {
get_key_value_int(w, NAMESPACE, NUM_LAUNCHES_KEY, 0).await
}

View File

@@ -1,18 +1,16 @@
extern crate core;
#[cfg(target_os = "macos")]
extern crate objc;
use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::encoding::read_response_body;
use crate::grpc::metadata_to_map;
use crate::http_request::send_http_request;
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
use crate::updates::{UpdateMode, YaakUpdater};
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use eventsource_client::{EventParser, SSE};
use log::{debug, error, warn};
use rand::random;
use regex::Regex;
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
use std::fs::{create_dir_all, File};
use std::path::PathBuf;
@@ -30,8 +28,30 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place;
use yaak_grpc::manager::{DynamicMessage, GrpcHandle};
use yaak_grpc::{deserialize_message, serialize_message, Code, ServiceDefinition};
use yaak_models::models::{CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue, ModelType, Plugin, Settings, WebsocketRequest, Workspace, WorkspaceMeta};
use yaak_models::queries::{batch_upsert, cancel_pending_grpc_connections, cancel_pending_responses, create_default_http_response, delete_all_grpc_connections, delete_all_grpc_connections_for_workspace, delete_all_http_responses_for_request, delete_all_http_responses_for_workspace, delete_all_websocket_connections_for_workspace, delete_cookie_jar, delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request, delete_http_response, delete_plugin, delete_workspace, duplicate_folder, duplicate_grpc_request, duplicate_http_request, ensure_base_environment, generate_model_id, get_base_environment, get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_or_create_workspace_meta, get_plugin, get_workspace, get_workspace_export_resources, list_cookie_jars, list_environments, list_folders, list_grpc_connections_for_workspace, list_grpc_events, list_grpc_requests, list_http_requests, list_http_responses_for_workspace, list_key_values_raw, list_plugins, list_workspaces, set_key_value_raw, update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_plugin, upsert_workspace, upsert_workspace_meta, BatchUpsertResult, UpdateSource};
use yaak_models::models::{
CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState,
GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue,
ModelType, Plugin, Settings, WebsocketRequest, Workspace, WorkspaceMeta,
};
use yaak_models::queries::{
batch_upsert, cancel_pending_grpc_connections, cancel_pending_responses,
create_default_http_response, delete_all_grpc_connections,
delete_all_grpc_connections_for_workspace, delete_all_http_responses_for_request,
delete_all_http_responses_for_workspace, delete_all_websocket_connections_for_workspace,
delete_cookie_jar, delete_environment, delete_folder, delete_grpc_connection,
delete_grpc_request, delete_http_request, delete_http_response, delete_plugin,
delete_workspace, duplicate_folder, duplicate_grpc_request, duplicate_http_request,
ensure_base_environment, generate_model_id, get_base_environment, get_cookie_jar,
get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request,
get_http_response, get_key_value_raw, get_or_create_settings, get_or_create_workspace_meta,
get_plugin, get_workspace, get_workspace_export_resources, list_cookie_jars, list_environments,
list_folders, list_grpc_connections_for_workspace, list_grpc_events, list_grpc_requests,
list_http_requests, list_http_responses_for_workspace, list_key_values_raw, list_plugins,
list_workspaces, set_key_value_raw, update_response_if_id, update_settings, upsert_cookie_jar,
upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event,
upsert_grpc_request, upsert_http_request, upsert_plugin, upsert_workspace,
upsert_workspace_meta, BatchUpsertResult, UpdateSource,
};
use yaak_plugins::events::{
BootResponse, CallHttpAuthenticationRequest, CallHttpRequestActionRequest, FilterResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
@@ -44,9 +64,9 @@ use yaak_sse::sse::ServerSentEvent;
use yaak_templates::format::format_json;
use yaak_templates::{Parser, Tokens};
mod analytics;
mod encoding;
mod grpc;
mod history;
mod http_request;
mod notifications;
mod plugin_events;
@@ -807,7 +827,7 @@ async fn cmd_import_data<R: Runtime>(
.await
.unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
let file_contents = file.as_str();
let (import_result, plugin_name) =
let import_result =
plugin_manager.import_data(&window, file_contents).await.map_err(|e| e.to_string())?;
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
@@ -923,14 +943,6 @@ async fn cmd_import_data<R: Runtime>(
.await
.map_err(|e| e.to_string())?;
analytics::track_event(
&window,
AnalyticsResource::App,
AnalyticsAction::Import,
Some(json!({ "plugin": plugin_name })),
)
.await;
Ok(upserted)
}
@@ -1007,17 +1019,9 @@ async fn cmd_curl_to_request<R: Runtime>(
plugin_manager: State<'_, PluginManager>,
workspace_id: &str,
) -> Result<HttpRequest, String> {
let (import_result, plugin_name) =
let import_result =
{ plugin_manager.import_data(&window, command).await.map_err(|e| e.to_string())? };
analytics::track_event(
&window,
AnalyticsResource::App,
AnalyticsAction::Import,
Some(json!({ "plugin": plugin_name })),
)
.await;
import_result.resources.http_requests.get(0).ok_or("No curl command found".to_string()).map(
|r| {
let mut request = r.clone();
@@ -1051,8 +1055,6 @@ async fn cmd_export_data(
f.sync_all().expect("Failed to sync");
analytics::track_event(&window, AnalyticsResource::App, AnalyticsAction::Export, None).await;
Ok(())
}
@@ -1131,28 +1133,6 @@ async fn response_err<R: Runtime>(
response
}
#[tauri::command]
async fn cmd_track_event(
window: WebviewWindow,
resource: &str,
action: &str,
attributes: Option<Value>,
) -> Result<(), String> {
match (AnalyticsResource::from_str(resource), AnalyticsAction::from_str(action)) {
(Ok(resource), Ok(action)) => {
analytics::track_event(&window, resource, action, attributes).await;
}
(r, a) => {
error!(
"Invalid action/resource for track_event: {resource}.{action} = {:?}.{:?}",
r, a
);
return Err("Invalid analytics event".to_string());
}
};
Ok(())
}
#[tauri::command]
async fn cmd_set_update_mode(update_mode: &str, w: WebviewWindow) -> Result<KeyValue, String> {
cmd_set_key_value("app", "update_mode", update_mode, w).await.map_err(|e| e.to_string())
@@ -1678,8 +1658,8 @@ async fn cmd_new_child_window(
url,
inner_size: Some(inner_size),
position: Some(position),
navigation_tx: None,
hide_titlebar: true,
..Default::default()
};
let child_window = window::create_window(&app_handle, config);
@@ -1739,7 +1719,12 @@ async fn cmd_check_for_updates(
yaak_updater: State<'_, Mutex<YaakUpdater>>,
) -> Result<bool, String> {
let update_mode = get_update_mode(&app_handle).await;
yaak_updater.lock().await.force_check(&app_handle, update_mode).await.map_err(|e| e.to_string())
yaak_updater
.lock()
.await
.check_now(&app_handle, update_mode, UpdateTrigger::User)
.await
.map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -1918,7 +1903,6 @@ pub fn run() {
cmd_set_update_mode,
cmd_template_functions,
cmd_template_tokens_to_string,
cmd_track_event,
cmd_uninstall_plugin,
cmd_update_cookie_jar,
cmd_update_environment,
@@ -1941,7 +1925,7 @@ pub fn run() {
RunEvent::Ready => {
let w = create_main_window(app_handle, "/");
tauri::async_runtime::spawn(async move {
let info = analytics::track_launch_event(&w).await;
let info = history::store_launch_history(&w).await;
debug!("Launched Yaak {:?}", info);
});
@@ -1961,7 +1945,7 @@ pub fn run() {
tauri::async_runtime::spawn(async move {
let val: State<'_, Mutex<YaakUpdater>> = h.state();
let update_mode = get_update_mode(&h).await;
if let Err(e) = val.lock().await.check(&h, update_mode).await {
if let Err(e) = val.lock().await.maybe_check(&h, update_mode).await {
warn!("Failed to check for updates {e:?}");
};
});
@@ -2030,8 +2014,8 @@ fn create_main_window(handle: &AppHandle, url: &str) -> WebviewWindow {
100.0 + random::<f64>() * 20.0,
100.0 + random::<f64>() * 20.0,
)),
navigation_tx: None,
hide_titlebar: true,
..Default::default()
};
window::create_window(handle, config)
@@ -2070,7 +2054,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
// We might have recursive back-and-forth calls between app and plugin, so we don't
// want to block here
tauri::async_runtime::spawn(async move {
crate::plugin_events::handle_plugin_event(&app_handle, &event, &plugin).await;
plugin_events::handle_plugin_event(&app_handle, &event, &plugin).await;
});
}
plugin_manager.unsubscribe(rx_id.as_str()).await;

View File

@@ -1,6 +1,6 @@
use std::time::SystemTime;
use crate::analytics::{get_num_launches, get_os};
use crate::history::{get_num_launches, get_os};
use chrono::{DateTime, Duration, Utc};
use log::debug;
use reqwest::Method;

View File

@@ -30,7 +30,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
event: &InternalEvent,
plugin_handle: &PluginHandle,
) {
// info!("Got event to app {}", event.id);
// debug!("Got event to app {event:?}");
let window_context = event.window_context.to_owned();
let response_event: Option<InternalEventPayload> = match event.clone().payload {
InternalEventPayload::CopyTextRequest(req) => {
@@ -204,32 +204,55 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
}
InternalEventPayload::OpenWindowRequest(req) => {
let label = req.label;
let (tx, mut rx) = tokio::sync::mpsc::channel(128);
let (navigation_tx, mut navigation_rx) = tokio::sync::mpsc::channel(128);
let (close_tx, mut close_rx) = tokio::sync::mpsc::channel(128);
let win_config = CreateWindowConfig {
url: &req.url,
label: &label.clone(),
title: &req.title.unwrap_or_default(),
navigation_tx: Some(tx),
navigation_tx: Some(navigation_tx),
close_tx: Some(close_tx),
inner_size: req.size.map(|s| (s.width, s.height)),
position: None,
hide_titlebar: false,
data_dir_key: req.data_dir_key,
..Default::default()
};
create_window(app_handle, win_config);
let event_id = event.id.clone();
let plugin_handle = plugin_handle.clone();
tauri::async_runtime::spawn(async move {
while let Some(url) = rx.recv().await {
let label = label.clone();
let url = url.to_string();
let event_to_send = plugin_handle.build_event_to_send(
&WindowContext::Label { label },
&InternalEventPayload::WindowNavigateEvent(WindowNavigateEvent { url }),
Some(event_id.clone()),
);
plugin_handle.send(&event_to_send).await.unwrap();
}
});
{
let event_id = event.id.clone();
let plugin_handle = plugin_handle.clone();
let label = label.clone();
tauri::async_runtime::spawn(async move {
while let Some(url) = navigation_rx.recv().await {
let url = url.to_string();
let label = label.clone();
let event_to_send = plugin_handle.build_event_to_send(
&WindowContext::Label { label },
&InternalEventPayload::WindowNavigateEvent(WindowNavigateEvent { url }),
Some(event_id.clone()),
);
plugin_handle.send(&event_to_send).await.unwrap();
}
});
}
{
let event_id = event.id.clone();
let plugin_handle = plugin_handle.clone();
let label = label.clone();
tauri::async_runtime::spawn(async move {
while let Some(_) = close_rx.recv().await {
let label = label.clone();
let event_to_send = plugin_handle.build_event_to_send(
&WindowContext::Label { label },
&InternalEventPayload::WindowCloseEvent,
Some(event_id.clone()),
);
plugin_handle.send(&event_to_send).await.unwrap();
}
});
}
None
}
InternalEventPayload::CloseWindowRequest(req) => {

View File

@@ -6,6 +6,7 @@ use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
use tauri_plugin_updater::UpdaterExt;
use tokio::task::block_in_place;
use yaak_models::queries::get_or_create_settings;
use yaak_plugins::manager::PluginManager;
use crate::is_dev;
@@ -46,6 +47,11 @@ impl UpdateMode {
}
}
pub enum UpdateTrigger {
Background,
User,
}
impl YaakUpdater {
pub fn new() -> Self {
Self {
@@ -53,11 +59,14 @@ impl YaakUpdater {
}
}
pub async fn force_check(
pub async fn check_now(
&mut self,
app_handle: &AppHandle,
mode: UpdateMode,
update_trigger: UpdateTrigger,
) -> Result<bool, tauri_plugin_updater::Error> {
let settings = get_or_create_settings(app_handle).await;
let update_key = format!("{:x}", md5::compute(settings.id));
self.last_update_check = SystemTime::now();
info!("Checking for updates mode={}", mode);
@@ -79,6 +88,14 @@ impl YaakUpdater {
});
})
.header("X-Update-Mode", mode.to_string())?
.header("X-Update-Key", update_key)?
.header(
"X-Update-Trigger",
match update_trigger {
UpdateTrigger::Background => "background",
UpdateTrigger::User => "user",
},
)?
.build()?
.check()
.await;
@@ -129,7 +146,7 @@ impl YaakUpdater {
Err(e) => Err(e),
}
}
pub async fn check(
pub async fn maybe_check(
&mut self,
app_handle: &AppHandle,
mode: UpdateMode,
@@ -150,6 +167,6 @@ impl YaakUpdater {
return Ok(false);
}
self.force_check(app_handle, mode).await
self.check_now(app_handle, mode, UpdateTrigger::Background).await
}
}

View File

@@ -3,7 +3,7 @@ use crate::{DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MIN_WINDOW_HEIGHT, MIN_
use log::{info, warn};
use std::process::exit;
use tauri::{
AppHandle, Emitter, LogicalSize, Manager, Runtime, WebviewUrl, WebviewWindow,
AppHandle, Emitter, LogicalSize, Manager, Runtime, WebviewUrl, WebviewWindow, WindowEvent,
};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::mpsc;
@@ -16,6 +16,8 @@ pub(crate) struct CreateWindowConfig<'s> {
pub inner_size: Option<(f64, f64)>,
pub position: Option<(f64, f64)>,
pub navigation_tx: Option<mpsc::Sender<String>>,
pub close_tx: Option<mpsc::Sender<()>>,
pub data_dir_key: Option<String>,
pub hide_titlebar: bool,
}
@@ -41,6 +43,22 @@ pub(crate) fn create_window<R: Runtime>(
.disable_drag_drop_handler() // Required for frontend Dnd on windows
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
if let Some(key) = config.data_dir_key {
#[cfg(not(target_os = "macos"))]
{
use std::fs;
let dir = handle.path().temp_dir().unwrap().join("yaak_sessions").join(key);
fs::create_dir_all(dir.clone()).unwrap();
win_builder = win_builder.data_directory(dir);
}
// macOS doesn't support data dir so must use this fn instead
#[cfg(target_os = "macos")]
{
win_builder = win_builder.data_store_identifier(to_fixed_hash(&key));
}
}
if let Some((w, h)) = config.inner_size {
win_builder = win_builder.inner_size(w, h);
} else {
@@ -85,6 +103,18 @@ pub(crate) fn create_window<R: Runtime>(
let win = win_builder.build().unwrap();
if let Some(tx) = config.close_tx {
win.on_window_event(move |event| match event {
WindowEvent::CloseRequested { .. } => {
let tx = tx.clone();
tauri::async_runtime::block_on(async move {
tx.send(()).await.unwrap();
});
}
_ => {}
});
}
let webview_window = win.clone();
win.on_menu_event(move |w, event| {
if !w.is_focused().unwrap() {
@@ -128,3 +158,10 @@ pub(crate) fn create_window<R: Runtime>(
win
}
fn to_fixed_hash(s: &str) -> [u8; 16] {
let hash = md5::compute(s.as_bytes());
let mut fixed = [0u8; 16];
fixed.copy_from_slice(&hash[..16]); // Take the first 16 bytes of the hash
fixed
}

View File

@@ -102,9 +102,20 @@ async function getToken(ctx, contextId) {
async function deleteToken(ctx, contextId) {
return ctx.store.delete(tokenStoreKey(contextId));
}
async function resetDataDirKey(ctx, contextId) {
const key = (/* @__PURE__ */ new Date()).toISOString();
return ctx.store.set(dataDirStoreKey(contextId), key);
}
async function getDataDirKey(ctx, contextId) {
const key = await ctx.store.get(dataDirStoreKey(contextId)) ?? "default";
return `${contextId}::${key}`;
}
function tokenStoreKey(context_id) {
return ["token", context_id].join("::");
}
function dataDirStoreKey(context_id) {
return ["data_dir", context_id].join("::");
}
// src/getOrRefreshAccessToken.ts
async function getOrRefreshAccessToken(ctx, contextId, {
@@ -218,9 +229,16 @@ async function getAuthorizationCode(ctx, contextId, {
return new Promise(async (resolve, reject) => {
const authorizationUrlStr = authorizationUrl.toString();
console.log("Authorizing", authorizationUrlStr);
let foundCode = false;
let { close } = await ctx.window.openUrl({
url: authorizationUrlStr,
label: "oauth-authorization-url",
dataDirKey: await getDataDirKey(ctx, contextId),
async onClose() {
if (!foundCode) {
reject(new Error("Authorization window closed"));
}
},
async onNavigate({ url: urlStr }) {
const url = new URL(urlStr);
if (url.searchParams.has("error")) {
@@ -230,6 +248,7 @@ async function getAuthorizationCode(ctx, contextId, {
if (!code) {
return;
}
foundCode = true;
close();
const response = await getAccessToken(ctx, {
grantType: "authorization_code",
@@ -428,7 +447,6 @@ var plugin = {
actions: [
{
label: "Copy Current Token",
icon: "copy",
async onSelect(ctx, { contextId }) {
const token = await getToken(ctx, contextId);
if (token == null) {
@@ -441,7 +459,6 @@ var plugin = {
},
{
label: "Delete Token",
icon: "trash",
async onSelect(ctx, { contextId }) {
if (await deleteToken(ctx, contextId)) {
await ctx.toast.show({ message: "Token deleted", color: "success" });
@@ -449,6 +466,12 @@ var plugin = {
await ctx.toast.show({ message: "No token to delete", color: "warning" });
}
}
},
{
label: "Clear Window Session",
async onSelect(ctx, { contextId }) {
await resetDataDirKey(ctx, contextId);
}
}
],
args: [
@@ -461,17 +484,24 @@ var plugin = {
options: grantTypes
},
// Always-present fields
{ type: "text", name: "clientId", label: "Client ID" },
{
type: "text",
name: "clientId",
label: "Client ID",
optional: true
},
{
type: "text",
name: "clientSecret",
label: "Client Secret",
optional: true,
password: true,
dynamic: hiddenIfNot(["authorization_code", "password", "client_credentials"])
},
{
type: "text",
name: "authorizationUrl",
optional: true,
label: "Authorization URL",
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
placeholder: authorizationUrls[0],
@@ -480,6 +510,7 @@ var plugin = {
{
type: "text",
name: "accessTokenUrl",
optional: true,
label: "Access Token URL",
placeholder: accessTokenUrls[0],
dynamic: hiddenIfNot(["authorization_code", "password", "client_credentials"]),
@@ -590,55 +621,55 @@ var plugin = {
}
],
async onApply(ctx, { values, contextId }) {
const headerPrefix = optionalString(values, "headerPrefix") ?? "";
const grantType = requiredString(values, "grantType");
const headerPrefix = stringArg(values, "headerPrefix");
const grantType = stringArg(values, "grantType");
const credentialsInBody = values.credentials === "body";
let token;
if (grantType === "authorization_code") {
const authorizationUrl = requiredString(values, "authorizationUrl");
const accessTokenUrl = requiredString(values, "accessTokenUrl");
const authorizationUrl = stringArg(values, "authorizationUrl");
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getAuthorizationCode(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`,
authorizationUrl: authorizationUrl.match(/^https?:\/\//) ? authorizationUrl : `https://${authorizationUrl}`,
clientId: requiredString(values, "clientId"),
clientSecret: requiredString(values, "clientSecret"),
redirectUri: optionalString(values, "redirectUri"),
scope: optionalString(values, "scope"),
state: optionalString(values, "state"),
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
redirectUri: stringArgOrNull(values, "redirectUri"),
scope: stringArgOrNull(values, "scope"),
state: stringArgOrNull(values, "state"),
credentialsInBody,
pkce: values.usePkce ? {
challengeMethod: requiredString(values, "pkceChallengeMethod"),
codeVerifier: optionalString(values, "pkceCodeVerifier")
challengeMethod: stringArg(values, "pkceChallengeMethod"),
codeVerifier: stringArgOrNull(values, "pkceCodeVerifier")
} : null
});
} else if (grantType === "implicit") {
const authorizationUrl = requiredString(values, "authorizationUrl");
const authorizationUrl = stringArg(values, "authorizationUrl");
token = await getImplicit(ctx, contextId, {
authorizationUrl: authorizationUrl.match(/^https?:\/\//) ? authorizationUrl : `https://${authorizationUrl}`,
clientId: requiredString(values, "clientId"),
redirectUri: optionalString(values, "redirectUri"),
responseType: requiredString(values, "responseType"),
scope: optionalString(values, "scope"),
state: optionalString(values, "state")
clientId: stringArg(values, "clientId"),
redirectUri: stringArgOrNull(values, "redirectUri"),
responseType: stringArg(values, "responseType"),
scope: stringArgOrNull(values, "scope"),
state: stringArgOrNull(values, "state")
});
} else if (grantType === "client_credentials") {
const accessTokenUrl = requiredString(values, "accessTokenUrl");
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getClientCredentials(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`,
clientId: requiredString(values, "clientId"),
clientSecret: requiredString(values, "clientSecret"),
scope: optionalString(values, "scope"),
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
scope: stringArgOrNull(values, "scope"),
credentialsInBody
});
} else if (grantType === "password") {
const accessTokenUrl = requiredString(values, "accessTokenUrl");
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getPassword(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`,
clientId: requiredString(values, "clientId"),
clientSecret: requiredString(values, "clientSecret"),
username: requiredString(values, "username"),
password: requiredString(values, "password"),
scope: optionalString(values, "scope"),
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
username: stringArg(values, "username"),
password: stringArg(values, "password"),
scope: stringArgOrNull(values, "scope"),
credentialsInBody
});
} else {
@@ -654,14 +685,14 @@ var plugin = {
}
}
};
function optionalString(values, name) {
function stringArgOrNull(values, name) {
const arg = values[name];
if (arg == null || arg == "") return null;
return `${arg}`;
}
function requiredString(values, name) {
const arg = optionalString(values, name);
if (!arg) throw new Error(`Missing required argument ${name}`);
function stringArg(values, name) {
const arg = stringArgOrNull(values, name);
if (!arg) return "";
return arg;
}
// Annotate the CommonJS export names for ESM import in node:

View File

@@ -1,5 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SyncModel } from "./gen_models";
import type { SyncModel } from "./gen_models.js";
export type GitAuthor = { name: string | null, email: string | null, };

View File

@@ -51,6 +51,14 @@ export function useGit(dir: string) {
mutationFn: (args) => invoke('plugin:yaak-git|commit', { dir, ...args }),
onSuccess,
}),
commitAndPush: useMutation<PushResult, string, { message: string }>({
mutationKey: ['git', 'commitpush', dir],
mutationFn: async (args) => {
await invoke('plugin:yaak-git|commit', { dir, ...args });
return invoke('plugin:yaak-git|push', { dir });
},
onSuccess,
}),
fetchAll: useMutation<string, string, void>({
mutationKey: ['git', 'checkout', dir],
mutationFn: () => invoke('plugin:yaak-git|fetch_all', { dir }),

View File

@@ -26,6 +26,7 @@ export function useLicense() {
const CHECK_QUERY_KEY = ['license.check'];
const check = useQuery<void, string, LicenseCheckStatus>({
refetchInterval: 1000 * 60 * 60 * 12, // Refetch every 12 hours
queryKey: CHECK_QUERY_KEY,
queryFn: () => invoke('plugin:yaak-license|check'),
});

View File

@@ -54,7 +54,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, editorFontSize: number, editorSoftWrap: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, telemetry: boolean, theme: string, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, theme: string, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, };
export type SyncHistory = { model: "sync_history", id: string, workspaceId: string, createdAt: string, states: Array<SyncState>, checksum: string, relPath: string, syncDir: string, };

View File

@@ -87,7 +87,6 @@ pub struct Settings {
pub interface_scale: f32,
pub open_workspace_new_window: Option<bool>,
pub proxy: Option<ProxySetting>,
pub telemetry: bool,
pub theme: String,
pub theme_dark: String,
pub theme_light: String,
@@ -112,7 +111,6 @@ pub enum SettingsIden {
InterfaceScale,
OpenWorkspaceNewWindow,
Proxy,
Telemetry,
Theme,
ThemeDark,
ThemeLight,
@@ -138,7 +136,6 @@ impl<'s> TryFrom<&Row<'s>> for Settings {
interface_scale: r.get("interface_scale")?,
open_workspace_new_window: r.get("open_workspace_new_window")?,
proxy: proxy.map(|p| -> ProxySetting { serde_json::from_str(p.as_str()).unwrap() }),
telemetry: r.get("telemetry")?,
theme: r.get("theme")?,
theme_dark: r.get("theme_dark")?,
theme_light: r.get("theme_light")?,

View File

@@ -1478,7 +1478,6 @@ pub async fn update_settings<R: Runtime>(
(SettingsIden::EditorFontSize, settings.editor_font_size.into()),
(SettingsIden::EditorKeymap, settings.editor_keymap.to_string().into()),
(SettingsIden::EditorSoftWrap, settings.editor_soft_wrap.into()),
(SettingsIden::Telemetry, settings.telemetry.into()),
(SettingsIden::OpenWorkspaceNewWindow, settings.open_workspace_new_window.into()),
(
SettingsIden::Proxy,

View File

@@ -346,7 +346,7 @@ export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, windowContext: WindowContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & EmptyPayload | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & EmptyPayload | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type JsonPrimitive = string | number | boolean | null;
@@ -354,7 +354,7 @@ export type OpenWindowRequest = { url: string,
/**
* Label for the window. If not provided, a random one will be generated.
*/
label: string, title?: string, size?: WindowSize, };
label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

View File

@@ -3,7 +3,9 @@ use std::collections::HashMap;
use tauri::{Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_models::models::{Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest, Workspace};
use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest, Workspace,
};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
@@ -108,6 +110,7 @@ pub enum InternalEventPayload {
OpenWindowRequest(OpenWindowRequest),
WindowNavigateEvent(WindowNavigateEvent),
WindowCloseEvent,
CloseWindowRequest(CloseWindowRequest),
TemplateRenderRequest(TemplateRenderRequest),
@@ -262,10 +265,15 @@ pub struct OpenWindowRequest {
pub url: String,
/// Label for the window. If not provided, a random one will be generated.
pub label: String,
#[ts(optional)]
pub title: Option<String>,
#[ts(optional)]
pub size: Option<WindowSize>,
#[ts(optional)]
pub data_dir_key: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]

View File

@@ -206,7 +206,7 @@ impl PluginManager {
// Boot the plugin
let event = timeout(
Duration::from_secs(1),
Duration::from_secs(2),
self.send_to_plugin_and_wait(
window_context,
&plugin_handle,
@@ -559,7 +559,7 @@ impl PluginManager {
Some(JsonPrimitive::Boolean(v)) => v.clone(),
_ => false,
};
// Auth is disabled, so don't do anything
if disabled {
info!("Not applying disabled auth {:?}", auth_name);
@@ -623,7 +623,7 @@ impl PluginManager {
&self,
window: &WebviewWindow<R>,
content: &str,
) -> Result<(ImportResponse, String)> {
) -> Result<ImportResponse> {
let reply_events = self
.send_and_wait(
&WindowContext::from_window(window),
@@ -635,19 +635,13 @@ impl PluginManager {
// TODO: Don't just return the first valid response
let result = reply_events.into_iter().find_map(|e| match e.payload {
InternalEventPayload::ImportResponse(resp) => Some((resp, e.plugin_ref_id)),
InternalEventPayload::ImportResponse(resp) => Some(resp),
_ => None,
});
match result {
None => Err(PluginErr("No importers found for file contents".to_string())),
Some((resp, ref_id)) => {
let plugin = self
.get_plugin_by_ref_id(ref_id.as_str())
.await
.ok_or(PluginNotFoundErr(ref_id))?;
Ok((resp, plugin.info().await.name))
}
Some(resp) => Ok(resp),
}
}

View File

@@ -74,6 +74,7 @@ impl PluginHandle {
}
pub async fn set_boot_response(&self, resp: &BootResponse) {
info!("BOOTED PLUGIN {:?}", resp);
let mut boot_resp = self.boot_resp.lock().await;
*boot_resp = resp.clone();
}

View File

@@ -77,5 +77,7 @@ function removeWatchKey(key: string) {
// On page load, unlisten to all zombie watchers
const keys = getWatchKeys();
console.log('Unsubscribing to zombie file watchers', keys);
keys.forEach(unlistenToWatcher);
if (keys.length > 0) {
console.log('Unsubscribing to zombie file watchers', keys);
keys.forEach(unlistenToWatcher);
}

View File

@@ -1,7 +1,7 @@
use crate::error::Result;
use crate::models::SyncModel;
use chrono::Utc;
use log::{debug, warn};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
@@ -164,6 +164,13 @@ pub(crate) async fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
let path = dir_entry.path();
let (model, checksum) = match SyncModel::from_file(&path) {
// TODO: Remove this once we have logic to handle environments. This it to clean
// any existing ones from the sync dir that resulted from the 2025.1 betas.
Ok(Some((SyncModel::Environment(e), _))) => {
fs::remove_file(path).await?;
info!("Cleaned up synced environment {}", e.id);
continue;
}
Ok(Some(m)) => m,
Ok(None) => continue,
Err(e) => {
@@ -205,9 +212,17 @@ pub(crate) fn compute_sync_ops(
let op = match (db_map.get(k), fs_map.get(k)) {
(None, None) => return None, // Can never happen
(None, Some(fs)) => SyncOp::DbCreate { fs: fs.to_owned() },
(Some(DbCandidate::Unmodified(model, sync_state)), None) => SyncOp::DbDelete {
model: model.to_owned(),
state: sync_state.to_owned(),
(Some(DbCandidate::Unmodified(model, sync_state)), None) => {
// TODO: Remove this once we have logic to handle environments. This it to
// ignore the cleaning we did above of any environments that were written
// to disk in the 2025.1 betas.
if let SyncModel::Environment(_) = model {
return None;
}
SyncOp::DbDelete {
model: model.to_owned(),
state: sync_state.to_owned(),
}
},
(Some(DbCandidate::Modified(model, sync_state)), None) => SyncOp::FsUpdate {
model: model.to_owned(),
@@ -318,7 +333,7 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
);
let mut sync_state_ops = Vec::new();
let mut workspaces_to_upsert = Vec::new();
let mut environments_to_upsert = Vec::new();
let environments_to_upsert = Vec::new();
let mut folders_to_upsert = Vec::new();
let mut http_requests_to_upsert = Vec::new();
let mut grpc_requests_to_upsert = Vec::new();
@@ -380,11 +395,13 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
// batch upsert to make foreign keys happy
match fs.model {
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
SyncModel::Environment(m) => environments_to_upsert.push(m),
SyncModel::Folder(m) => folders_to_upsert.push(m),
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),
SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),
// TODO: Handle environments in sync
SyncModel::Environment(_) => {}
};
SyncStateOp::Create {
model_id,
@@ -397,11 +414,13 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
// batch upsert to make foreign keys happy
match fs.model {
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
SyncModel::Environment(m) => environments_to_upsert.push(m),
SyncModel::Folder(m) => folders_to_upsert.push(m),
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),
SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),
// TODO: Handle environments in sync
SyncModel::Environment(_) => {}
}
SyncStateOp::Update {
state: state.to_owned(),

View File

@@ -5,7 +5,6 @@ import { InlineCode } from '../components/core/InlineCode';
import { VStack } from '../components/core/Stacks';
import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { showConfirm } from '../lib/confirm';
import { resolvedModelNameWithFolders } from '../lib/resolvedModelName';
import { pluralizeCount } from '../lib/pluralize';
@@ -42,7 +41,6 @@ export const createFolder = createFastMutation<
patch.sortPriority = patch.sortPriority || -Date.now();
return invokeCmd<Folder>('cmd_update_folder', { folder: { workspaceId, ...patch } });
},
onSettled: () => trackEvent('folder', 'create'),
});
export const syncWorkspace = createFastMutation<

View File

@@ -1,14 +1,10 @@
import type { WebsocketConnection } from '@yaakapp-internal/models';
import { deleteWebsocketConnection as cmdDeleteWebsocketConnection } from '@yaakapp-internal/ws';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
export const deleteWebsocketConnection = createFastMutation({
mutationKey: ['delete_websocket_connection'],
mutationFn: async function (connection: WebsocketConnection) {
return cmdDeleteWebsocketConnection(connection.id);
},
onSuccess: async () => {
trackEvent('websocket_connection', 'delete');
},
});

View File

@@ -1,14 +1,10 @@
import type { WebsocketRequest } from '@yaakapp-internal/models';
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from '@yaakapp-internal/ws';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
export const deleteWebsocketConnections = createFastMutation({
mutationKey: ['delete_websocket_connections'],
mutationFn: async function (request: WebsocketRequest) {
return cmdDeleteWebsocketConnections(request.id);
},
onSuccess: async () => {
trackEvent('websocket_connection', 'delete_many');
},
});

View File

@@ -2,7 +2,6 @@ import type { WebsocketRequest } from '@yaakapp-internal/models';
import { deleteWebsocketRequest as cmdDeleteWebsocketRequest } from '@yaakapp-internal/ws';
import { InlineCode } from '../components/core/InlineCode';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { showConfirmDelete } from '../lib/confirm';
import { resolvedModelName } from '../lib/resolvedModelName';
@@ -24,7 +23,4 @@ export const deleteWebsocketRequest = createFastMutation({
return cmdDeleteWebsocketRequest(request.id);
},
onSuccess: async () => {
trackEvent('websocket_request', 'delete');
},
});

View File

@@ -1,16 +1,13 @@
import type { WebsocketRequest } from '@yaakapp-internal/models';
import { duplicateWebsocketRequest as cmdDuplicateWebsocketRequest } from '@yaakapp-internal/ws';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { router } from '../lib/router';
export const duplicateWebsocketRequest = createFastMutation({
mutationKey: ['delete_websocket_connection'],
mutationFn: async function (request: WebsocketRequest) {
return cmdDuplicateWebsocketRequest(request.id);
mutationFn: async function (requestId: string) {
return cmdDuplicateWebsocketRequest(requestId);
},
onSuccess: async (request) => {
trackEvent('websocket_request', 'duplicate');
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: request.workspaceId },

View File

@@ -1,7 +1,6 @@
import { SettingsTab } from '../components/Settings/SettingsTab';
import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
@@ -11,7 +10,6 @@ export const openSettings = createFastMutation<void, string, SettingsTab | null>
const workspaceId = getActiveWorkspaceId();
if (workspaceId == null) return;
trackEvent('dialog', 'show', { id: 'settings', tab: `${tab}` });
const location = router.buildLocation({
to: '/workspaces/$workspaceId/settings',
params: { workspaceId },

View File

@@ -1,20 +1,11 @@
import { open } from '@tauri-apps/plugin-dialog';
import { applySync, calculateSyncFsOnly } from '@yaakapp-internal/sync';
import { createFastMutation } from '../hooks/useFastMutation';
import { showSimpleAlert } from '../lib/alert';
import { router } from '../lib/router';
export const openWorkspaceFromSyncDir = createFastMutation<void>({
export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
mutationKey: [],
mutationFn: async () => {
const dir = await open({
title: 'Select Workspace Directory',
directory: true,
multiple: false,
});
if (dir == null) return;
mutationFn: async (dir) => {
const ops = await calculateSyncFsOnly(dir);
const workspace = ops

View File

@@ -2,7 +2,6 @@ import type { WebsocketRequest } from '@yaakapp-internal/models';
import { upsertWebsocketRequest as cmdUpsertWebsocketRequest } from '@yaakapp-internal/ws';
import { differenceInMilliseconds } from 'date-fns';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { router } from '../lib/router';
export const upsertWebsocketRequest = createFastMutation<
@@ -16,12 +15,11 @@ export const upsertWebsocketRequest = createFastMutation<
const isNew = differenceInMilliseconds(new Date(), request.createdAt + 'Z') < 100;
if (isNew) {
trackEvent('websocket_request', 'create');
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: request.workspaceId },
search: (prev) => ({ ...prev, request_id: request.id }),
});
} else trackEvent('websocket_request', 'update');
}
},
});

View File

@@ -1,7 +1,5 @@
import type { Workspace } from '@yaakapp-internal/models';
import { differenceInMilliseconds } from 'date-fns';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
export const upsertWorkspace = createFastMutation<
@@ -11,10 +9,4 @@ export const upsertWorkspace = createFastMutation<
>({
mutationKey: ['upsert_workspace'],
mutationFn: (workspace) => invokeCmd<Workspace>('cmd_update_workspace', { workspace }),
onSuccess: async (workspace) => {
const isNew = differenceInMilliseconds(new Date(), workspace.createdAt + 'Z') < 100;
if (isNew) trackEvent('workspace', 'create');
else trackEvent('workspace', 'update');
},
});

View File

@@ -3,7 +3,7 @@ import { useMemo, type ReactNode } from 'react';
import { useSaveResponse } from '../hooks/useSaveResponse';
import { useToggle } from '../hooks/useToggle';
import { isProbablyTextContentType } from '../lib/contentType';
import { getContentTypeHeader } from '../lib/model_util';
import { getContentTypeFromHeaders } from '../lib/model_util';
import { getResponseBodyText } from '../lib/responseBody';
import { CopyButton } from './CopyButton';
import { Banner } from './core/Banner';
@@ -24,7 +24,7 @@ export function ConfirmLargeResponse({ children, response }: Props) {
const { mutate: saveResponse } = useSaveResponse(response);
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeHeader(response.headers);
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);

View File

@@ -20,7 +20,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
const [syncConfig, setSyncConfig] = useState<{
filePath: string | null;
initGit?: boolean;
}>({ filePath: null, initGit: true });
}>({ filePath: null, initGit: false });
return (
<VStack
@@ -62,8 +62,8 @@ export function CreateWorkspaceDialog({ hide }: Props) {
<SyncToFilesystemSetting
onChange={setSyncConfig}
onCreateNewWorkspace={hide}
value={syncConfig}
allowNonEmptyDirectory // Will do initial import when the workspace is created
/>
<Button type="submit" color="primary" className="ml-auto mt-3">
Create Workspace

View File

@@ -175,6 +175,8 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
useTemplating={useTemplating}
autocompleteVariables={autocompleteVariables}
/>
</div>
</details>
@@ -193,6 +195,8 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
useTemplating={useTemplating}
autocompleteVariables={autocompleteVariables}
/>
</Banner>
);

View File

@@ -32,10 +32,13 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const { baseEnvironment, subEnvironments, allEnvironments } = useEnvironments();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? baseEnvironment?.id ?? null,
initialEnvironment?.id ?? null,
);
const selectedEnvironment = allEnvironments.find((e) => e.id === selectedEnvironmentId);
const selectedEnvironment =
selectedEnvironmentId != null
? allEnvironments.find((e) => e.id === selectedEnvironmentId)
: baseEnvironment;
const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return;
@@ -55,7 +58,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
<div className="min-w-0 h-full overflow-y-auto pt-1">
<SidebarButton
active={selectedEnvironment?.id == baseEnvironment?.id}
onClick={() => setSelectedEnvironmentId(baseEnvironment?.id ?? null)}
onClick={() => setSelectedEnvironmentId(null)}
environment={null}
rightSlot={
<IconButton
@@ -82,6 +85,11 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
active={selectedEnvironment?.id === e.id}
environment={e}
onClick={() => setSelectedEnvironmentId(e.id)}
onDelete={() => {
if (e.id === selectedEnvironmentId) {
setSelectedEnvironmentId(null);
}
}}
>
{e.name}
</SidebarButton>
@@ -90,11 +98,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
</aside>
)}
secondSlot={() =>
selectedEnvironmentId == null ? (
<div className="p-3 mt-10">
<Banner color="danger">No selected environment</Banner>
</div>
) : selectedEnvironment == null ? (
selectedEnvironment == null ? (
<div className="p-3 mt-10">
<Banner color="danger">
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
@@ -198,6 +202,7 @@ function SidebarButton({
className,
active,
onClick,
onDelete,
rightSlot,
environment,
}: {
@@ -205,6 +210,7 @@ function SidebarButton({
children: ReactNode;
active: boolean;
onClick: () => void;
onDelete?: () => void;
rightSlot?: ReactNode;
environment: Environment | null;
}) {
@@ -275,7 +281,11 @@ function SidebarButton({
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: () => deleteEnvironment.mutate(),
onSelect: () => {
deleteEnvironment.mutate(undefined, {
onSuccess: onDelete,
});
},
},
]}
/>

View File

@@ -39,7 +39,7 @@ interface TreeNode {
}
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const [{ status }, { commit, add, unstage, push }] = useGit(syncDir);
const [{ status }, { commit, commitAndPush, add, unstage, push }] = useGit(syncDir);
const [message, setMessage] = useState<string>('');
const handleCreateCommit = async () => {
@@ -53,8 +53,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const handleCreateCommitAndPush = async () => {
try {
await commit.mutateAsync({ message });
await push.mutateAsync();
await commitAndPush.mutateAsync({ message });
showToast({ id: 'git-push-success', message: 'Pushed changes', color: 'success' });
onDone();
} catch (err) {
@@ -66,10 +65,13 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const allEntries = [];
const yaakEntries = [];
const externalEntries = [];
for (const entry of status.data?.entries ?? []) {
allEntries.push(entry);
if (entry.next == null && entry.prev == null) {
externalEntries.push(entry);
} else if (entry.next?.model === 'environment' || entry.prev?.model === 'environment') {
externalEntries.push(entry);
} else {
yaakEntries.push(entry);
}
@@ -184,7 +186,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
size="sm"
onClick={handleCreateCommit}
disabled={!hasAddedAnything}
isLoading={push.isPending || commit.isPending}
isLoading={push.isPending || commitAndPush.isPending || commit.isPending}
>
Commit
</Button>
@@ -193,7 +195,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
size="sm"
disabled={!hasAddedAnything}
onClick={handleCreateCommitAndPush}
isLoading={push.isPending || commit.isPending}
isLoading={push.isPending || commitAndPush.isPending || commit.isPending}
>
Commit and Push
</Button>

View File

@@ -336,12 +336,14 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
label: banner,
},
{
color: 'success',
label: 'Open Workspace Settings',
leftSlot: <Icon icon="settings" />,
onSelect() {
openWorkspaceSettings.mutate({ openSyncMenu: true });
},
},
{ type: 'separator' },
{
label: 'Hide This Message',
leftSlot: <Icon icon="eye_closed" />,
@@ -396,8 +398,8 @@ function SetupGitDropdown({
leftSlot: <Icon icon="magic_wand" />,
onSelect: initRepo,
},
{ type: 'separator' },
{
color: 'warning',
label: 'Hide This Message',
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {

View File

@@ -7,12 +7,10 @@ import type { CSSProperties } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication';
import { httpRequestsAtom } from '../hooks/useHttpRequests';
import { useImportCurl } from '../hooks/useImportCurl';
import { useImportQuerystring } from '../hooks/useImportQuerystring';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
@@ -20,7 +18,6 @@ import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { deepEqualAtom } from '../lib/atoms';
import { languageFromContentType } from '../lib/contentType';
import { resolvedModelName } from '../lib/resolvedModelName';
import { generateId } from '../lib/generateId';
import {
BODY_TYPE_BINARY,
@@ -30,8 +27,10 @@ import {
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML,
BODY_TYPE_XML, getContentTypeFromHeaders,
} from '../lib/model_util';
import { prepareImportQuerystring } from '../lib/prepareImportQuerystring';
import { resolvedModelName } from '../lib/resolvedModelName';
import { showToast } from '../lib/toast';
import { BinaryFileEditor } from './BinaryFileEditor';
import { CountBadge } from './core/CountBadge';
@@ -83,8 +82,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const [activeTabs, setActiveTabs] = useAtom(tabsAtom);
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }] = useRequestEditor();
const contentType = useContentTypeFromHeaders(activeRequest.headers);
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const contentType = getContentTypeFromHeaders(activeRequest.headers);
const authentication = useHttpAuthenticationSummaries();
const handleContentTypeChange = useCallback(
@@ -273,7 +272,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const { updateKey } = useRequestUpdateKey(activeRequestId);
const { mutate: importCurl } = useImportCurl();
const { mutate: importQuerystring } = useImportQuerystring(activeRequestId);
const handleBodyChange = useCallback(
(body: HttpRequest['body']) => updateRequest({ id: activeRequestId, update: { body } }),
@@ -314,17 +312,35 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);
const handlePaste = useCallback(
(text: string) => {
(e: ClipboardEvent, text: string) => {
if (text.startsWith('curl ')) {
importCurl({ overwriteRequestId: activeRequestId, command: text });
} else {
// Only import query if pasted text contains entire querystring
importQuerystring(text);
const data = prepareImportQuerystring(text);
if (data != null) {
e.preventDefault(); // Prevent input onChange
updateRequest({ id: activeRequestId, update: data });
focusParamsTab();
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
setTimeout(() => {
forceUrlRefresh();
forceParamsRefresh();
}, 100);
}
}
},
[activeRequestId, importCurl, importQuerystring],
[
activeRequestId,
focusParamsTab,
forceParamsRefresh,
forceUrlRefresh,
importCurl,
updateRequest,
],
);
const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null),
[activeRequest.id, sendRequest],

View File

@@ -3,9 +3,9 @@ import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { getContentTypeFromHeaders } from '../lib/model_util';
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
@@ -47,7 +47,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
'responsePaneActiveTabs',
{},
);
const contentType = useContentTypeFromHeaders(activeResponse?.headers ?? null);
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const tabs = useMemo<TabItem[]>(
() => [

View File

@@ -1,5 +1,5 @@
import { clear, readText } from '@tauri-apps/plugin-clipboard-manager';
import { motion } from 'framer-motion';
import * as m from 'motion/react-m';
import React, { useEffect, useState } from 'react';
import { useImportCurl } from '../hooks/useImportCurl';
import { useWindowFocus } from '../hooks/useWindowFocus';
@@ -22,7 +22,7 @@ export function ImportCurlButton() {
}
return (
<motion.div
<m.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
@@ -50,6 +50,6 @@ export function ImportCurlButton() {
>
Import Curl
</Button>
</motion.div>
</m.div>
);
}

View File

@@ -1,14 +1,14 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license';
import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../hooks/useAppInfo';
import { useLicenseConfirmation } from '../hooks/useLicenseConfirmation';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { HStack } from './core/Stacks';
import { openSettings } from '../commands/openSettings';
import {SettingsTab} from "./Settings/SettingsTab";
import { SettingsTab } from './Settings/SettingsTab';
const details: Record<
LicenseCheckStatus['type'] | 'dev' | 'beta',
@@ -26,22 +26,30 @@ const details: Record<
dev: { label: 'Develop', color: 'secondary' },
commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' },
personal_use: { label: 'Personal Use', color: 'primary' },
trialing: { label: 'Personal Use', color: 'primary' },
personal_use: { label: 'Personal Use', color: 'success' },
trialing: { label: 'Active Trial', color: 'success' },
};
export function LicenseBadge() {
const { check } = useLicense();
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
if (check.data == null) {
// Hasn't loaded yet
if (licenseDetails == null || check.data == null) {
return null;
}
const checkType = appInfo.version.includes('beta')
? 'beta'
: appInfo.isDev
? 'dev'
: check.data.type;
// User has confirmed they are using Yaak for personal use only, so hide badge
if (licenseDetails.confirmedPersonalUse) {
return null;
}
// User is trialing but has already seen the message, so hide badge
if (check.data.type === 'trialing' && licenseDetails.hasDismissedTrial) {
return null;
}
const checkType = appInfo.version.includes('beta') ? 'beta' : check.data.type;
const detail = details[checkType];
if (detail == null) {
return null;
@@ -52,15 +60,16 @@ export function LicenseBadge() {
size="2xs"
variant="border"
className="!rounded-full mx-1"
onClick={async () => {
if (checkType === 'beta') {
await openUrl('https://feedback.yaak.app');
} else {
openSettings.mutate(SettingsTab.License);
}
}}
color={detail.color}
event={{ id: 'license-badge', status: check.data.type }}
onClick={async () => {
if (check.data.type === 'trialing') {
await setLicenseDetails((v) => ({
...v,
hasDismissedTrial: true,
}));
}
openSettings.mutate(SettingsTab.License);
}}
>
{detail.label}
</Button>

View File

@@ -66,18 +66,8 @@ export function MarkdownEditor({
onChange={setViewMode}
value={viewMode}
options={[
{
event: { id: 'md_mode', mode: 'preview' },
icon: 'eye',
label: 'Preview mode',
value: 'preview',
},
{
event: { id: 'md_mode', mode: 'edit' },
icon: 'pencil',
label: 'Edit mode',
value: 'edit',
},
{ icon: 'eye', label: 'Preview mode', value: 'preview' },
{ icon: 'pencil', label: 'Edit mode', value: 'edit' },
]}
/>
</div>

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { motion } from 'framer-motion';
import * as m from 'motion/react-m';
import type { ReactNode } from 'react';
import React from 'react';
import { Portal } from './Portal';
@@ -48,7 +48,7 @@ export function Overlay({
<Portal name={portalName}>
{open && (
<FocusTrap>
<motion.div
<m.div
className={classNames('fixed inset-0', zIndexes[zIndex])}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -68,7 +68,7 @@ export function Overlay({
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
)}
{children}
</motion.div>
</m.div>
</FocusTrap>
)}
</Portal>

View File

@@ -96,7 +96,6 @@ export function SettingsAppearance() {
value={`${settings.interfaceFontSize}`}
options={fontSizeOptions}
onChange={(v) => updateSettings.mutate({ interfaceFontSize: parseInt(v) })}
event="ui-font-size"
/>
<Select
size="sm"
@@ -106,7 +105,6 @@ export function SettingsAppearance() {
value={`${settings.editorFontSize}`}
options={fontSizeOptions}
onChange={(v) => updateSettings.mutate({ editorFontSize: clamp(parseInt(v) || 14, 8, 30) })}
event="editor-font-size"
/>
<Select
size="sm"
@@ -116,13 +114,11 @@ export function SettingsAppearance() {
value={`${settings.editorKeymap}`}
options={keymaps}
onChange={(v) => updateSettings.mutate({ editorKeymap: v })}
event="editor-keymap"
/>
<Checkbox
checked={settings.editorSoftWrap}
title="Wrap Editor Lines"
onChange={(editorSoftWrap) => updateSettings.mutate({ editorSoftWrap })}
event="editor-wrap-lines"
/>
<Separator className="my-4" />
@@ -134,7 +130,6 @@ export function SettingsAppearance() {
size="sm"
value={settings.appearance}
onChange={(appearance) => updateSettings.mutate({ appearance })}
event="appearance"
options={[
{ label: 'Automatic', value: 'system' },
{ label: 'Light', value: 'light' },
@@ -152,7 +147,6 @@ export function SettingsAppearance() {
className="flex-1"
value={activeTheme.light.id}
options={lightThemes}
event="theme.light"
onChange={(themeLight) => updateSettings.mutate({ ...settings, themeLight })}
/>
)}
@@ -166,7 +160,6 @@ export function SettingsAppearance() {
size="sm"
value={activeTheme.dark.id}
options={darkThemes}
event="theme.dark"
onChange={(themeDark) => updateSettings.mutate({ ...settings, themeDark })}
/>
)}

View File

@@ -38,7 +38,6 @@ export function SettingsGeneral() {
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => updateSettings.mutate({ updateChannel })}
event="update-channel"
options={[
{ label: 'Stable (less frequent)', value: 'stable' },
{ label: 'Beta (more frequent)', value: 'beta' },
@@ -59,7 +58,6 @@ export function SettingsGeneral() {
labelPosition="left"
labelClassName="w-[14rem]"
size="sm"
event="workspace-switch-behavior"
value={
settings.openWorkspaceNewWindow === true
? 'new'
@@ -81,10 +79,10 @@ export function SettingsGeneral() {
<Checkbox
className="mt-3"
checked={settings.telemetry}
title="Send Usage Statistics"
event="usage-statistics"
onChange={(telemetry) => updateSettings.mutate({ telemetry })}
checked={false}
title="Send Usage Statistics (all tracking was removed in 2025.1.2)"
disabled
onChange={() => {}}
/>
<Separator className="my-4" />
@@ -115,7 +113,6 @@ export function SettingsGeneral() {
<Checkbox
checked={workspace.settingValidateCertificates}
title="Validate TLS Certificates"
event="validate-certs"
onChange={(settingValidateCertificates) =>
upsertWorkspace.mutate({ ...workspace, settingValidateCertificates })
}
@@ -124,7 +121,6 @@ export function SettingsGeneral() {
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow Redirects"
event="follow-redirects"
onChange={(settingFollowRedirects) =>
upsertWorkspace.mutate({
...workspace,

View File

@@ -1,60 +1,87 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { useLicense } from '@yaakapp-internal/license';
import { formatDistanceToNowStrict } from 'date-fns';
import { differenceInDays } from 'date-fns';
import React, { useState } from 'react';
import { useLicenseConfirmation } from '../../hooks/useLicenseConfirmation';
import { useToggle } from '../../hooks/useToggle';
import { pluralizeCount } from '../../lib/pluralize';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { Icon } from '../core/Icon';
import { Link } from '../core/Link';
import { PlainInput } from '../core/PlainInput';
import { HStack, VStack } from '../core/Stacks';
import { openUrl } from '@tauri-apps/plugin-opener';
export function SettingsLicense() {
const { check, activate } = useLicense();
const [key, setKey] = useState<string>('');
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
const [checked, setChecked] = useState<boolean>(false);
if (check.isPending) {
return null;
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 max-w-lg">
{check.data?.type === 'commercial_use' ? (
<Banner color="success">
<strong>License active!</strong> Enjoy using Yaak for commercial use.
</Banner>
) : (
<Banner color="primary" className="flex flex-col gap-3 max-w-lg">
{check.data?.type === 'trialing' && (
<p className="select-text">
<strong>
You have {formatDistanceToNowStrict(check.data.end)} remaining on your trial.
</strong>
</p>
)}
) : check.data?.type == 'trialing' ? (
<Banner color="success" className="flex flex-col gap-3 max-w-lg">
<p className="select-text">
A commercial license is required if using Yaak within a for-profit organization.{' '}
<Link href="https://yaak.app/pricing" className="text-notice">
Learn More
</Link>
You have{' '}
<strong>
{pluralizeCount('day', differenceInDays(check.data.end, new Date()))} remaining
</strong>{' '}
on your commercial use trial. Once the trial ends, Yaak will be limited to personal use
until a license is activated.
</p>
</Banner>
)}
) : check.data?.type == 'personal_use' && !licenseDetails?.confirmedPersonalUse ? (
<Banner color="success" className="flex flex-col gap-3 max-w-lg">
<p className="select-text">
Your 30-day trial has ended. Please activate a license or confirm how you&apos;re using
Yaak.
</p>
<form
className="flex flex-col gap-3 items-start"
onSubmit={async (e) => {
e.preventDefault();
await setLicenseDetails((v) => ({
...v,
confirmedPersonalUse: true,
}));
}}
>
<Checkbox
checked={checked}
onChange={setChecked}
title="I am only using Yaak for personal use"
/>
<Button type="submit" disabled={!checked} size="xs" variant="border" color="success">
Confirm
</Button>
</form>
</Banner>
) : null}
<p className="select-text">
A commercial license is required if using Yaak within a for-profit organization.{' '}
<Link href="https://yaak.app/pricing" className="text-notice">
Learn More
</Link>
</p>
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
{check.data?.type === 'commercial_use' ? (
<HStack space={2}>
<Button
variant="border"
color="secondary"
size="sm"
onClick={toggleActivateFormVisible}
event="license.another"
>
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
Activate Another License
</Button>
<Button
@@ -62,27 +89,20 @@ export function SettingsLicense() {
size="sm"
onClick={() => openUrl('https://yaak.app/dashboard')}
rightSlot={<Icon icon="external_link" />}
event="license.support"
>
Direct Support
</Button>
</HStack>
) : (
<HStack space={2}>
<Button
color="primary"
size="sm"
onClick={toggleActivateFormVisible}
event="license.activate"
>
<Button color="primary" size="sm" onClick={toggleActivateFormVisible}>
Activate
</Button>
<Button
color="secondary"
size="sm"
onClick={() => open('https://yaak.app/pricing?ref=app.yaak.desktop')}
onClick={() => openUrl('https://yaak.app/pricing?ref=app.yaak.desktop')}
rightSlot={<Icon icon="external_link" />}
event="license.purchase"
>
Purchase
</Button>
@@ -107,13 +127,7 @@ export function SettingsLicense() {
onChange={setKey}
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
/>
<Button
type="submit"
color="primary"
size="sm"
isLoading={activate.isPending}
event="license.submit"
>
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
Submit
</Button>
</VStack>

View File

@@ -66,7 +66,6 @@ export function SettingsPlugins() {
type="submit"
color="primary"
className="ml-auto"
event="plugin.add"
>
Add Plugin
</Button>
@@ -76,14 +75,12 @@ export function SettingsPlugins() {
icon="refresh"
title="Reload plugins"
spin={refreshPlugins.isPending}
event="plugin.reload"
onClick={() => refreshPlugins.mutate()}
/>
<IconButton
size="sm"
icon="help"
title="View documentation"
event="plugin.docs"
onClick={() => openUrl('https://feedback.yaak.app/help/articles/6911763-quick-start')}
/>
</HStack>
@@ -107,7 +104,6 @@ function PluginInfo({ plugin }: { plugin: Plugin }) {
size="sm"
icon="trash"
title="Uninstall plugin"
event="plugin.delete"
onClick={() => deletePlugin.mutate()}
/>
</td>

View File

@@ -19,7 +19,6 @@ export function SettingsProxy() {
hideLabel
size="sm"
value={settings.proxy?.type ?? 'automatic'}
event="proxy"
onChange={(v) => {
if (v === 'automatic') {
updateSettings.mutate({ proxy: undefined });

View File

@@ -1,4 +1,5 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { useLicense } from '@yaakapp-internal/license';
import { useRef } from 'react';
import { openSettings } from '../commands/openSettings';
import { useAppInfo } from '../hooks/useAppInfo';
@@ -12,6 +13,7 @@ import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
import { SettingsTab } from './Settings/SettingsTab';
export function SettingsDropdown() {
const importData = useImportData();
@@ -19,6 +21,7 @@ export function SettingsDropdown() {
const appInfo = useAppInfo();
const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates();
const { check } = useLicense();
useListenToTauriEvent('settings', () => openSettings.mutate(null));
@@ -56,6 +59,13 @@ export function SettingsDropdown() {
onSelect: () => exportData.mutate(),
},
{ type: 'separator', label: `Yaak v${appInfo.version}` },
{
label: 'Purchase License',
color: 'success',
hidden: check.data == null || check.data.type === 'commercial_use',
leftSlot: <Icon icon="circle_dollar_sign" />,
onSelect: () => openSettings.mutate(SettingsTab.License),
},
{
label: 'Check for Updates',
leftSlot: <Icon icon="update" />,
@@ -65,7 +75,7 @@ export function SettingsDropdown() {
label: 'Feedback',
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" />,
onSelect: () => openUrl('https://yaak.app/roadmap'),
onSelect: () => openUrl('https://yaak.app/feedback'),
},
{
label: 'Changelog',

View File

@@ -1,32 +1,54 @@
import { readDir } from '@tauri-apps/plugin-fs';
import { useState } from 'react';
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { VStack } from './core/Stacks';
import { SelectFile } from './SelectFile';
export interface SyncToFilesystemSettingProps {
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
onCreateNewWorkspace: () => void;
value: { filePath: string | null; initGit?: boolean };
allowNonEmptyDirectory?: boolean;
forceOpen?: boolean;
}
export function SyncToFilesystemSetting({
onChange,
onCreateNewWorkspace,
value,
allowNonEmptyDirectory,
forceOpen,
}: SyncToFilesystemSettingProps) {
const [error, setError] = useState<string | null>(null);
const [isNonEmpty, setIsNonEmpty] = useState<string | null>(null);
return (
<details open={forceOpen || value != null} className="w-full">
<details open={forceOpen || !!value.filePath} className="w-full">
<summary>Data directory {typeof value.initGit === 'boolean' && ' and Git'}</summary>
<VStack className="my-2" space={3}>
<Banner color="info">
Sync workspace data to folder as plain text files, ideal for backup and Git collaboration.
</Banner>
{error && <div className="text-danger">{error}</div>}
{isNonEmpty ? (
<Banner color="notice" className="flex flex-col gap-1.5">
<p>The selected directory must be empty. Did you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(isNonEmpty);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
) : (
<Banner color="info">
Sync workspace data to folder as plain text files, ideal for backup and Git
collaboration. Environments are excluded in order to keep your secrets private.
</Banner>
)}
<SelectFile
directory
@@ -36,12 +58,13 @@ export function SyncToFilesystemSetting({
onChange={async ({ filePath }) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0 && !allowNonEmptyDirectory) {
setError('The directory must be empty');
if (files.length > 0) {
setIsNonEmpty(filePath);
return;
}
}
setIsNonEmpty(null);
onChange({ ...value, filePath });
}}
/>

View File

@@ -1,4 +1,4 @@
import { AnimatePresence } from 'framer-motion';
import { AnimatePresence } from 'motion/react';
import { useAtomValue } from 'jotai';
import React, { type ReactNode } from 'react';
import { hideToast, toastsAtom } from '../lib/toast';

View File

@@ -18,7 +18,7 @@ type Props = Pick<HttpRequest, 'url'> & {
onSend: () => void;
onUrlChange: (url: string) => void;
onPaste?: (v: string) => void;
onPasteOverwrite?: (v: string) => void;
onPasteOverwrite?: InputProps['onPasteOverwrite'];
onCancel: () => void;
submitIcon?: IconProps['icon'] | null;
onMethodChange?: (method: string) => void;

View File

@@ -12,17 +12,17 @@ import { getActiveEnvironment } from '../hooks/useActiveEnvironment';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication';
import { useImportQuerystring } from '../hooks/useImportQuerystring';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import { requestsAtom } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useLatestWebsocketConnection } from '../hooks/useWebsocketConnections';
import { trackEvent } from '../lib/analytics';
import { deepEqualAtom } from '../lib/atoms';
import { languageFromContentType } from '../lib/contentType';
import { resolvedModelName } from '../lib/resolvedModelName';
import { generateId } from '../lib/generateId';
import { prepareImportQuerystring } from '../lib/prepareImportQuerystring';
import { resolvedModelName } from '../lib/resolvedModelName';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor/Editor';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
@@ -66,7 +66,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const activeRequestId = activeRequest.id;
const [activeTabs, setActiveTabs] = useAtom(tabsAtom);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }] = useRequestEditor();
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const authentication = useHttpAuthenticationSummaries();
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
@@ -151,8 +151,8 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const { mutate: updateRequest } = useUpdateAnyHttpRequest();
const { updateKey } = useRequestUpdateKey(activeRequestId);
const { mutate: importQuerystring } = useImportQuerystring(activeRequestId);
const connection = useLatestWebsocketConnection(activeRequestId);
const activeTab = activeTabs?.[activeRequestId];
@@ -189,7 +189,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
environmentId: getActiveEnvironment()?.id ?? null,
cookieJarId: getActiveCookieJar()?.id ?? null,
});
trackEvent('websocket_request', 'send');
}, [activeRequest.id]);
const handleSend = useCallback(async () => {
@@ -198,13 +197,11 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null,
});
trackEvent('websocket_connection', 'send');
}, [connection]);
const handleCancel = useCallback(async () => {
if (connection == null) return;
await closeWebsocket({ connectionId: connection?.id });
trackEvent('websocket_connection', 'cancel');
}, [connection]);
const handleUrlChange = useCallback(
@@ -212,6 +209,26 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
[activeRequest],
);
const handlePaste = useCallback(
(e: ClipboardEvent, text: string) => {
const data = prepareImportQuerystring(text);
if (data != null) {
e.preventDefault(); // Prevent input onChange
updateRequest({ id: activeRequestId, update: data });
focusParamsTab();
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
setTimeout(() => {
forceUrlRefresh();
forceParamsRefresh();
}, 100);
}
},
[activeRequestId, focusParamsTab, forceParamsRefresh, forceUrlRefresh, updateRequest],
);
const messageLanguage = languageFromContentType(null, activeRequest.message);
const isLoading = connection !== null && connection.state !== 'closed';
@@ -242,7 +259,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
)
}
placeholder="wss://example.com"
onPasteOverwrite={importQuerystring}
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={isLoading ? handleSend : handleConnect}
onCancel={cancelResponse}

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import * as m from 'motion/react-m';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {duplicateWebsocketRequest} from "../commands/duplicateWebsocketRequest";
@@ -137,7 +137,7 @@ export function Workspace() {
portalName="sidebar"
onClose={() => setFloatingSidebarHidden(true)}
>
<motion.div
<m.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className={classNames(
@@ -150,7 +150,7 @@ export function Workspace() {
<SidebarActions />
</HeaderSize>
<Sidebar />
</motion.div>
</m.div>
</Overlay>
) : (
<>
@@ -261,7 +261,7 @@ function useGlobalWorkspaceHooks() {
} else if (activeRequest.model === 'grpc_request') {
await duplicateGrpcRequest.mutateAsync();
} else if (activeRequest.model === 'websocket_request') {
await duplicateWebsocketRequest.mutateAsync(activeRequest);
await duplicateWebsocketRequest.mutateAsync(activeRequest.id);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
throw new Error('Failed to duplicate invalid request model: ' + (activeRequest as any).model);

View File

@@ -1,3 +1,4 @@
import {open} from "@tauri-apps/plugin-dialog";
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
@@ -75,7 +76,16 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
{
label: 'Open Existing Workspace',
leftSlot: <Icon icon="folder_open" />,
onSelect: openWorkspaceFromSyncDir.mutate,
onSelect: async () => {
const dir = await open({
title: 'Select Workspace Directory',
directory: true,
multiple: false,
});
if (dir == null) return;
openWorkspaceFromSyncDir.mutate(dir);
},
},
];

View File

@@ -46,7 +46,6 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
icon="search"
title="Search or execute a command"
size="sm"
event="search"
iconColor="secondary"
onClick={togglePalette}
/>

View File

@@ -63,6 +63,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, openSyncMenu }: Pro
<SyncToFilesystemSetting
value={{ filePath: workspaceMeta.settingSyncDir }}
forceOpen={openSyncMenu}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => {
upsertWorkspaceMeta.mutate({ ...workspaceMeta, settingSyncDir: filePath });
}}

View File

@@ -9,17 +9,19 @@ interface Props {
export function Banner({ children, className, color }: Props) {
return (
<div
className={classNames(
className,
`x-theme-banner--${color}`,
'whitespace-pre-wrap',
'border border-border bg-surface',
'px-4 py-3 rounded-lg select-auto',
'overflow-auto h-auto mb-auto text-text',
)}
>
{children}
<div className="w-full mb-auto grid grid-rows-1 max-h-full">
<div
className={classNames(
className,
`x-theme-banner--${color}`,
'whitespace-pre-wrap',
'border border-border bg-surface',
'px-4 py-3 rounded-lg select-auto',
'overflow-auto text-text',
)}
>
{children}
</div>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon';
@@ -22,7 +21,6 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onC
leftSlot?: ReactNode;
rightSlot?: ReactNode;
hotkeyAction?: HotkeyAction;
event?: string | { id: string; [attr: string]: number | string };
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
@@ -43,7 +41,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
hotkeyAction,
title,
onClick,
event,
...props
}: ButtonProps,
ref,
@@ -107,12 +104,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
type={type}
className={classes}
disabled={disabled}
onClick={(e) => {
onClick?.(e);
if (event != null) {
trackEvent('button', 'click', typeof event === 'string' ? { id: event } : event);
}
}}
onClick={onClick}
onDoubleClick={(e) => {
// Kind of a hack? This prevents double-clicks from going through buttons. For example, when
// double-clicking the workspace header to toggle window maximization
@@ -136,7 +128,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{children}
</div>
{rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && <Icon icon="check" size={size} className="ml-1 -mr-1" />}
{forDropdown && <Icon icon="chevron_down" size={size} className="ml-1 -mr-1" />}
</button>
);
});

View File

@@ -1,6 +1,5 @@
import classNames from 'classnames';
import { type ReactNode } from 'react';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon';
import { HStack } from './Stacks';
@@ -13,7 +12,6 @@ export interface CheckboxProps {
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
event?: string;
}
export function Checkbox({
@@ -25,7 +23,6 @@ export function Checkbox({
title,
hideLabel,
fullWidth,
event,
}: CheckboxProps) {
return (
<HStack as="label" space={2} className={classNames(className, 'text-text mr-auto')}>
@@ -42,9 +39,6 @@ export function Checkbox({
disabled={disabled}
onChange={() => {
onChange(checked === 'indeterminate' ? true : !checked);
if (event != null) {
trackEvent('button', 'click', { id: event, checked: checked ? 'on' : 'off' });
}
}}
/>
<div className="absolute inset-0 flex items-center justify-center">

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import * as m from 'motion/react-m';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { useKey } from 'react-use';
@@ -65,7 +65,7 @@ export function Dialog({
aria-labelledby={titleId}
aria-describedby={descriptionId}
>
<motion.div
<m.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classNames(
@@ -122,7 +122,7 @@ export function Dialog({
/>
</div>
)}
</motion.div>
</m.div>
</div>
</Overlay>
);

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import * as m from 'motion/react-m';
import { atom } from 'jotai';
import type {
CSSProperties,
@@ -55,7 +55,7 @@ export type DropdownItemDefault = {
label: ReactNode;
hotKeyAction?: HotkeyAction;
hotKeyLabelOnly?: boolean;
color?: 'default' | 'danger' | 'info' | 'warning' | 'notice';
color?: 'default' | 'primary' | 'danger' | 'info' | 'warning' | 'notice' | 'success';
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
@@ -111,20 +111,23 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
const setIsOpen = useCallback((o: SetStateAction<boolean>) => {
jotaiStore.set(openAtom, (prevId) => {
const prevIsOpen = prevId === id.current;
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
// Persist background color of button until we close the dropdown
if (newIsOpen) {
onOpen?.();
buttonRef.current!.style.backgroundColor = window
.getComputedStyle(buttonRef.current!)
.getPropertyValue('background-color');
}
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
});
}, [onOpen]);
const setIsOpen = useCallback(
(o: SetStateAction<boolean>) => {
jotaiStore.set(openAtom, (prevId) => {
const prevIsOpen = prevId === id.current;
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
// Persist background color of button until we close the dropdown
if (newIsOpen) {
onOpen?.();
buttonRef.current!.style.backgroundColor = window
.getComputedStyle(buttonRef.current!)
.getPropertyValue('background-color');
}
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
});
},
[onOpen],
);
// Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
// we have of detecting the dropdown closed, to do cleanup.
@@ -494,7 +497,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
)}
{isOpen && (
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
<motion.div
<m.div
ref={menuRef}
tabIndex={0}
onKeyDown={handleMenuKeyDown}
@@ -571,7 +574,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
);
})}
</VStack>
</motion.div>
</m.div>
</Overlay>
)}
</>
@@ -642,6 +645,8 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
'focus:bg-surface-highlight focus:text rounded',
item.color === 'danger' && '!text-danger',
item.color === 'primary' && '!text-primary',
item.color === 'success' && '!text-success',
item.color === 'warning' && '!text-warning',
item.color === 'notice' && '!text-notice',
item.color === 'info' && '!text-info',

View File

@@ -71,7 +71,7 @@ export interface EditorProps {
useTemplating?: boolean;
onChange?: (value: string) => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: (value: string) => void;
onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
@@ -173,7 +173,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
}, [onPaste]);
// Use ref so we can update the handler without re-initializing the editor
const handlePasteOverwrite = useRef<EditorProps['onPasteOverwrite']>(onPaste);
const handlePasteOverwrite = useRef<EditorProps['onPasteOverwrite']>(onPasteOverwrite);
useEffect(() => {
handlePasteOverwrite.current = onPasteOverwrite;
}, [onPasteOverwrite]);
@@ -606,7 +606,7 @@ function getExtensions({
const textData = e.clipboardData?.getData('text/plain') ?? '';
onPaste.current?.(textData);
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
onPasteOverwrite.current?.(textData);
onPasteOverwrite.current?.(e, textData);
}
},
}),

View File

@@ -21,6 +21,7 @@ import {
import { lintKeymap } from '@codemirror/lint';
import { searchKeymap } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
import {
crosshairCursor,
@@ -73,6 +74,8 @@ export const syntaxHighlightStyle = HighlightStyle.define([
const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExts: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const syntaxExtensions: Record<NonNullable<EditorProps['language']>, LanguageSupport | null> = {
graphql: null,
json: json(),
@@ -85,6 +88,8 @@ const syntaxExtensions: Record<NonNullable<EditorProps['language']>, LanguageSup
markdown: markdown(),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript'];
export function getLanguageExtension({
language,
useTemplating = false,
@@ -110,7 +115,13 @@ export function getLanguageExtension({
return base;
}
const extraExtensions = language === 'url' ? [pathParametersPlugin(onClickPathParameter)] : [];
const extraExtensions: Extension[] =
language === 'url' ? [pathParametersPlugin(onClickPathParameter)] : [];
// Only close brackets on languages that need it
if (language && closeBracketsFor.includes(language)) {
extraExtensions.push(closeBracketsExts);
}
return twig({
base,
@@ -209,9 +220,8 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
}),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
closeBrackets(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLineGutter(),
keymap.of([...closeBracketsKeymap, ...searchKeymap, ...foldKeymap, ...lintKeymap]),
keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]),
];

View File

@@ -8,6 +8,7 @@ const icons = {
alert_triangle: lucide.AlertTriangleIcon,
archive: lucide.ArchiveIcon,
arrow_big_down_dash: lucide.ArrowBigDownDashIcon,
circle_dollar_sign: lucide.CircleDollarSignIcon,
arrow_right_circle: lucide.ArrowRightCircleIcon,
arrow_big_left_dash: lucide.ArrowBigLeftDashIcon,
arrow_big_right: lucide.ArrowBigRightIcon,

View File

@@ -35,7 +35,7 @@ export type InputProps = Pick<
onFocus?: () => void;
onBlur?: () => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: (value: string) => void;
onPasteOverwrite?: EditorProps['onPasteOverwrite'];
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;

View File

@@ -1,15 +1,13 @@
import { Link as RouterLink } from '@tanstack/react-router';
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { Link as RouterLink } from '@tanstack/react-router';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon';
interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string;
event?: string;
}
export function Link({ href, children, className, event, ...other }: Props) {
export function Link({ href, children, className, ...other }: Props) {
const isExternal = href.match(/^https?:\/\//);
className = classNames(className, 'relative underline hover:text-violet-600');
@@ -23,9 +21,6 @@ export function Link({ href, children, className, event, ...other }: Props) {
className={classNames(className, 'pr-4 inline-flex items-center')}
onClick={(e) => {
e.preventDefault();
if (event != null) {
trackEvent('link', 'click', { id: event });
}
}}
{...other}
>

View File

@@ -261,7 +261,6 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
variant="border"
className="m-2"
size="xs"
event="pairs.reveal-more"
>
Show {pairs.length - MAX_INITIAL_PAIRS} More
</Button>

View File

@@ -2,18 +2,17 @@ import classNames from 'classnames';
import { useRef } from 'react';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import type { IconProps } from './Icon';
import type { IconButtonProps } from './IconButton';
import { IconButton } from './IconButton';
import { HStack } from './Stacks';
interface Props<T extends string> {
options: { value: T; label: string; icon: IconProps['icon']; event?: IconButtonProps['event'] }[];
options: { value: T; label: string; icon: IconProps['icon'] }[];
onChange: (value: T) => void;
value: T;
name: string;
}
export function SegmentedControl<T extends string>({ value, onChange, options, name }: Props<T>) {
export function SegmentedControl<T extends string>({ value, onChange, options }: Props<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null);
return (
@@ -45,14 +44,13 @@ export function SegmentedControl<T extends string>({ value, onChange, options, n
<IconButton
size="xs"
variant="solid"
color={isActive ? "secondary" : undefined}
color={isActive ? 'secondary' : undefined}
role="radio"
event={{ id: name, value: String(o.value) }}
tabIndex={isSelected ? 0 : -1}
className={classNames(
isActive && '!text-text',
'!px-1.5 !w-auto',
'focus:ring-border-focus',
isActive && '!text-text',
'!px-1.5 !w-auto',
'focus:ring-border-focus',
)}
key={i}
title={o.label}

View File

@@ -2,7 +2,6 @@ import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import { useState } from 'react';
import { useOsInfo } from '../../hooks/useOsInfo';
import { trackEvent } from '../../lib/analytics';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import { Label } from './Label';
@@ -22,7 +21,6 @@ export interface SelectProps<T extends string> {
onChange: (value: T) => void;
size?: ButtonProps['size'];
className?: string;
event?: string;
disabled?: boolean;
}
@@ -38,7 +36,6 @@ export function Select<T extends string>({
leftSlot,
onChange,
className,
event,
size = 'md',
}: SelectProps<T>) {
const osInfo = useOsInfo();
@@ -48,9 +45,6 @@ export function Select<T extends string>({
const handleChange = (value: T) => {
onChange?.(value);
if (event != null) {
trackEvent('select', 'click', { id: event, value });
}
};
return (

View File

@@ -1,7 +1,6 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useEffect, useRef } from 'react';
import { trackEvent } from '../../../lib/analytics';
import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
@@ -104,14 +103,7 @@ export function Tabs({
onChange={t.options.onChange}
>
<button
onClick={
isActive
? undefined
: () => {
trackEvent('tab', 'click', { label, tab: t.value });
onChangeValue(t.value);
}
}
onClick={isActive ? undefined : () => onChangeValue(t.value)}
className={btnClassName}
>
{option && 'shortLabel' in option && option.shortLabel
@@ -133,14 +125,7 @@ export function Tabs({
return (
<button
key={t.value}
onClick={
isActive
? undefined
: () => {
trackEvent('tab', 'click', { label, tab: t.value });
onChangeValue(t.value);
}
}
onClick={isActive ? undefined : () => onChangeValue(t.value)}
className={btnClassName}
>
{t.label}

View File

@@ -1,6 +1,6 @@
import type { ShowToastRequest } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import * as m from 'motion/react-m';
import type { ReactNode } from 'react';
import React from 'react';
import { useKey } from 'react-use';
@@ -45,7 +45,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
const toastIcon = icon ?? (color && color in ICONS && ICONS[color]);
return (
<motion.div
<m.div
initial={{ opacity: 0, right: '-10%' }}
animate={{ opacity: 100, right: 0 }}
exit={{ opacity: 0, right: '-100%' }}
@@ -80,7 +80,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
{timeout != null && (
<div className="w-full absolute bottom-0 left-0 right-0">
<motion.div
<m.div
className="bg-surface-highlight h-[3px]"
initial={{ width: '100%' }}
animate={{ width: '0%', opacity: 0.2 }}
@@ -89,6 +89,6 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
</div>
)}
</div>
</motion.div>
</m.div>
);
}

View File

@@ -1,6 +1,6 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useSaveResponse } from '../../hooks/useSaveResponse';
import { getContentTypeHeader } from '../../lib/model_util';
import { getContentTypeFromHeaders } from '../../lib/model_util';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import { InlineCode } from '../core/InlineCode';
@@ -13,7 +13,7 @@ interface Props {
export function BinaryViewer({ response }: Props) {
const saveResponse = useSaveResponse(response);
const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
const contentType = getContentTypeFromHeaders(response.headers) ?? 'unknown';
// Wait until the response has been fully-downloaded
if (response.state === 'closed') {

View File

@@ -1,7 +1,7 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { languageFromContentType } from '../../lib/contentType';
import { getContentTypeFromHeaders } from '../../lib/model_util';
import { BinaryViewer } from './BinaryViewer';
import { TextViewer } from './TextViewer';
import { WebPageViewer } from './WebPageViewer';
@@ -14,10 +14,8 @@ interface Props {
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
const rawTextBody = useResponseBodyText(response);
const language = languageFromContentType(
useContentTypeFromHeaders(response.headers),
rawTextBody.data ?? '',
);
const contentType = getContentTypeFromHeaders(response.headers);
const language = languageFromContentType(contentType, rawTextBody.data ?? '');
if (rawTextBody.isLoading || response.state === 'initialized') {
return null;

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { useFloatingSidebarHidden } from '../../hooks/useFloatingSidebarHidden';
import { useShouldFloatSidebar } from '../../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../../hooks/useSidebarHidden';
import { trackEvent } from '../../lib/analytics';
import { IconButton } from '../core/IconButton';
import { HStack } from '../core/Stacks';
import { CreateDropdown } from '../CreateDropdown';
@@ -22,8 +21,6 @@ export function SidebarActions() {
<HStack className="h-full">
<IconButton
onClick={async () => {
trackEvent('sidebar', 'toggle');
// NOTE: We're not using the (h) => !h pattern here because the data
// might be different if another window changed it (out of sync)
await setHidden(!hidden);

View File

@@ -1,11 +1,13 @@
import React, { useMemo } from 'react';
import { duplicateWebsocketRequest } from '../../commands/duplicateWebsocketRequest';
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
import { useDeleteFolder } from '../../hooks/useDeleteFolder';
import { useDeleteAnyRequest } from '../../hooks/useDeleteAnyRequest';
import { useDeleteFolder } from '../../hooks/useDeleteFolder';
import { useDuplicateFolder } from '../../hooks/useDuplicateFolder';
import { useDuplicateGrpcRequest } from '../../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../../hooks/useDuplicateHttpRequest';
import { useHttpRequestActions } from '../../hooks/useHttpRequestActions';
import { getHttpRequest } from '../../hooks/useHttpRequests';
import { useMoveToWorkspace } from '../../hooks/useMoveToWorkspace';
import { useRenameRequest } from '../../hooks/useRenameRequest';
import { useSendAnyHttpRequest } from '../../hooks/useSendAnyHttpRequest';
@@ -18,7 +20,6 @@ import { ContextMenu } from '../core/Dropdown';
import { Icon } from '../core/Icon';
import { FolderSettingsDialog } from '../FolderSettingsDialog';
import type { SidebarTreeNode } from './Sidebar';
import { getHttpRequest } from '../../hooks/useHttpRequests';
interface Props {
child: SidebarTreeNode;
@@ -110,10 +111,17 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
hotKeyAction: 'http_request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () =>
child.model === 'http_request'
? duplicateHttpRequest.mutate()
: duplicateGrpcRequest.mutate(),
onSelect: () => {
if (child.model === 'http_request') {
duplicateHttpRequest.mutate();
} else if (child.model === 'grpc_request') {
duplicateGrpcRequest.mutate();
} else if (child.model === 'websocket_request') {
duplicateWebsocketRequest.mutate(child.id);
} else {
throw new Error('Cannot duplicate invalid model: ' + child.model);
}
},
},
{
label: 'Move',

View File

@@ -1,11 +1,9 @@
import { useFastMutation } from './useFastMutation';
import { event } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
export function useCancelHttpResponse(id: string | null) {
return useFastMutation<void>({
mutationKey: ['cancel_http_response', id],
mutationFn: () => event.emit(`cancel_http_response_${id}`),
onSettled: () => trackEvent('http_response', 'cancel'),
});
}

View File

@@ -1,9 +0,0 @@
import type { HttpResponseHeader } from '@yaakapp-internal/models';
import { useMemo } from 'react';
export function useContentTypeFromHeaders(headers: HttpResponseHeader[] | null): string | null {
return useMemo(
() => headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null,
[headers],
);
}

View File

@@ -1,5 +1,4 @@
import type { CookieJar } from '@yaakapp-internal/models';
import { trackEvent } from '../lib/analytics';
import { showPrompt } from '../lib/prompt';
import { invokeCmd } from '../lib/tauri';
import { getActiveWorkspaceId } from './useActiveWorkspace';
@@ -25,6 +24,5 @@ export function useCreateCookieJar() {
return invokeCmd('cmd_create_cookie_jar', { workspaceId, name });
},
onSettled: () => trackEvent('cookie_jar', 'create'),
});
}

View File

@@ -1,5 +1,4 @@
import type { Environment } from '@yaakapp-internal/models';
import { trackEvent } from '../lib/analytics';
import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { invokeCmd } from '../lib/tauri';
@@ -33,7 +32,6 @@ export function useCreateEnvironment() {
environmentId: baseEnvironment.id,
});
},
onSettled: () => trackEvent('environment', 'create'),
onSuccess: async (environment) => {
if (environment == null) return;
setWorkspaceSearchParams({ environment_id: environment.id });

View File

@@ -1,5 +1,4 @@
import type { GrpcRequest } from '@yaakapp-internal/models';
import { trackEvent } from '../lib/analytics';
import { jotaiStore } from '../lib/jotai';
import { invokeCmd } from '../lib/tauri';
import { getActiveRequest } from './useActiveRequest';
@@ -36,7 +35,6 @@ export function useCreateGrpcRequest() {
...patch,
});
},
onSettled: () => trackEvent('grpc_request', 'create'),
onSuccess: async (request) => {
await router.navigate({
to: '/workspaces/$workspaceId',

View File

@@ -1,5 +1,4 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import { trackEvent } from '../lib/analytics';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
import { getActiveRequest } from './useActiveRequest';
@@ -30,7 +29,6 @@ export function useCreateHttpRequest() {
request: { workspaceId, ...patch },
});
},
onSettled: () => trackEvent('http_request', 'create'),
onSuccess: async (request) => {
await router.navigate({
to: '/workspaces/$workspaceId',

View File

@@ -1,6 +1,5 @@
import type { Workspace } from '@yaakapp-internal/models';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import { showConfirmDelete } from '../lib/confirm';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
@@ -24,7 +23,6 @@ export function useDeleteActiveWorkspace() {
if (!confirmed) return null;
return invokeCmd('cmd_delete_workspace', { workspaceId: workspace?.id });
},
onSettled: () => trackEvent('workspace', 'delete'),
onSuccess: async (workspace) => {
if (workspace === null) return;
await router.navigate({ to: '/workspaces' });

View File

@@ -1,6 +1,5 @@
import type { GrpcRequest } from '@yaakapp-internal/models';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import { showConfirmDelete } from '../lib/confirm';
import { resolvedModelName } from '../lib/resolvedModelName';
import { invokeCmd } from '../lib/tauri';
@@ -24,6 +23,5 @@ export function useDeleteAnyGrpcRequest() {
}
return invokeCmd('cmd_delete_grpc_request', { requestId: request.id });
},
onSuccess: () => trackEvent('grpc_request', 'delete'),
});
}

View File

@@ -1,6 +1,5 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import { showConfirmDelete } from '../lib/confirm';
import { resolvedModelName } from '../lib/resolvedModelName';
import { invokeCmd } from '../lib/tauri';
@@ -24,6 +23,5 @@ export function useDeleteAnyHttpRequest() {
}
return invokeCmd<HttpRequest>('cmd_delete_http_request', { requestId: request.id });
},
onSuccess: () => trackEvent('http_request', 'delete'),
});
}

View File

@@ -1,7 +1,6 @@
import type { CookieJar } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import { showConfirmDelete } from '../lib/confirm';
import { invokeCmd } from '../lib/tauri';
import { cookieJarsAtom } from './useCookieJars';
@@ -26,7 +25,6 @@ export function useDeleteCookieJar(cookieJar: CookieJar | null) {
if (!confirmed) return null;
return invokeCmd('cmd_delete_cookie_jar', { cookieJarId: cookieJar?.id });
},
onSettled: () => trackEvent('cookie_jar', 'delete'),
onSuccess: (cookieJar) => {
if (cookieJar == null) return;

View File

@@ -1,7 +1,6 @@
import type { Environment } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import { showConfirmDelete } from '../lib/confirm';
import { invokeCmd } from '../lib/tauri';
import { environmentsAtom } from './useEnvironments';
@@ -26,7 +25,6 @@ export function useDeleteEnvironment(environment: Environment | null) {
if (!confirmed) return null;
return invokeCmd('cmd_delete_environment', { environmentId: environment?.id });
},
onSettled: () => trackEvent('environment', 'delete'),
onSuccess: (environment) => {
if (environment == null) return;

Some files were not shown because too many files have changed in this diff Show More