Compare commits

..

54 Commits

Author SHA1 Message Date
Gregory Schier
4d1dda0786 Fix auth none 2025-05-23 08:43:52 -07:00
Gregory Schier
31605881ac Render inherited headers in UI 2025-05-23 08:18:29 -07:00
Gregory Schier
4cd2e9cd31 Request Inheritance (#209) 2025-05-23 08:13:25 -07:00
nguyen
13d959799a fix: prevent button stealing focus from url input (#212) 2025-05-23 08:12:06 -07:00
Pannawich Lohanimit
a6b18c23e1 fix: change incorrect default GraphQL request name (#213) 2025-05-23 08:11:16 -07:00
Gregory Schier
041298b3f8 Detect JSON language if application/javascript returns JSON 2025-05-21 11:05:20 -07:00
Gregory Schier
b400940f0e Fix import curl 2025-05-21 11:04:57 -07:00
Gregory Schier
2e144f064d Fix syntax highlighting 2025-05-21 08:26:15 -07:00
Gregory Schier
d8b1cadae6 Fix model deletion 2025-05-21 08:25:12 -07:00
Gregory Schier
c2f9760d08 Fix template parsing 2025-05-21 08:18:09 -07:00
Gregory Schier
a4c600cb48 Lint errors 2025-05-20 08:15:19 -07:00
Gregory Schier
bc3a5e3e58 Include license status in notification endpoint 2025-05-20 08:13:57 -07:00
Gregory Schier
4c3a02ac53 Show decrypt error in secure input 2025-05-20 07:41:32 -07:00
Gregory Schier
1974d61aa4 Fix syntax highlighting 2025-05-19 15:41:19 -07:00
Gregory Schier
0bcb092854 Update README.md 2025-05-19 15:10:56 -07:00
Gregory Schier
409620f533 More advanced template grammar
Fixes https://feedback.yaak.app/p/cannot-escape-call-to-variable-in-json-body
2025-05-19 13:37:12 -07:00
Gregory Schier
3e9037f70a No longer mark environments as external in Git 2025-05-17 06:06:36 -07:00
Desperate Necromancer
be82b67ed3 Allow disabling window decorations/controls (#176)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2025-05-16 13:33:59 -07:00
Gregory Schier
432b366105 Fix grpc/ws events error 2025-05-16 13:00:50 -07:00
Hao Xiang
42e70b941d fix proto to json-schema (#194) 2025-05-16 12:53:53 -07:00
Gregory Schier
3808215210 Better unicode un-escaping 2025-05-16 12:42:08 -07:00
Walyson G Oliveira
763a60982a Adjusting the JSON viewing response to accept accentuation (#203) 2025-05-16 12:37:00 -07:00
dependabot[bot]
a05679fd93 Bump vite from 6.2.6 to 6.2.7 in /src-web (#205)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 12:31:46 -07:00
Gregory Schier
73c366dc27 Hopefully fix weird env routing issue 2025-05-16 09:12:36 -07:00
Gregory Schier
0be7d0283b Add ref=<app_id> to external links pointing to yaak.app 2025-05-16 07:53:22 -07:00
Gregory Schier
749df338c5 Disable wasm-opt 2025-05-15 14:29:29 -07:00
Gregory Schier
3184c1b79e Remove dynamic imports 2025-05-15 12:29:45 -07:00
Gregory Schier
b52bf7cd56 Fix HttpResponse AnyModel deserialization 2025-05-15 09:37:05 -07:00
Gregory Schier
d962d7f94b remove codemirror dep and restructure a bit 2025-05-15 09:28:14 -07:00
Gregory Schier
21e2a67c1e Fix sidebar scroll drag
https://feedback.yaak.app/p/endpoinst-scrollbar-not-clickable
2025-05-15 07:10:08 -07:00
Gregory Schier
c188435524 Fix obscured text overflow
https://feedback.yaak.app/p/pasting-token-auth-results-in-invisible-text
2025-05-15 07:00:25 -07:00
Gregory Schier
8a7a7ba49d Try fixing trusted-signing-cli 2025-05-14 20:43:59 -07:00
Gregory Schier
cbc40230bb Fix cursor position after variable on Linux
Closes https://feedback.yaak.app/p/editing-the-url-sometimes-freezes-the-app
2025-05-14 20:05:04 -07:00
Gregory Schier
bc4c3178c9 Add Content-Length: 0 default for post/put/patch
https://feedback.yaak.app/p/missing-content-length
2025-05-13 21:58:00 -07:00
Gregory Schier
121fe5b3ea Fix help text 2025-05-13 11:53:46 -07:00
Gregory Schier
861609ddc0 Update encryption help 2025-05-13 11:38:02 -07:00
Gregory Schier
e5070513ac Regenerate types 2025-05-13 10:45:41 -07:00
Gregory Schier
f5c3798df9 Ability to disable proxy config
Closes https://feedback.yaak.app/p/proxy-save-last-data
2025-05-13 10:35:02 -07:00
Gregory Schier
469d12fede Don't query KeyValue.id == NULL 2025-05-13 10:11:24 -07:00
Gregory Schier
417a02744b Don't select <Input/> text when focus is due to window focus
Closes https://feedback.yaak.app/p/url-input-auto-selects-all-text-when-regaining-focus-after
2025-05-12 22:19:16 -07:00
Gregory Schier
81e78ef24c Fix auth padding 2025-05-12 16:57:43 -07:00
Gregory Schier
dad9cebb9e Don't send empty ? for ws query params 2025-05-12 16:57:13 -07:00
Gregory Schier
b3ede3d6d6 Add error boundaries 2025-05-12 15:53:21 -07:00
Gregory Schier
035fe54df0 Send grpc metadata/auth with reflection requests
Closes https://feedback.yaak.app/p/send-metadata-during-grpc-reflection
2025-05-11 07:20:57 -07:00
Gregory Schier
5f8d99ba64 Build plugins 2025-05-11 06:46:51 -07:00
Gregory Schier
84b8d130dc Some small tweaks for plugins 2025-05-11 06:36:50 -07:00
Gregory Schier
94d4227bc1 Ability to sync environments to folder (#207) 2025-05-08 14:10:07 -07:00
Gregory Schier
77cdea2f9f Merge remote-tracking branch 'origin/master' 2025-05-08 08:02:32 -07:00
Gregory Schier
8b1ca4cb47 Fix copy response body reference
Closes https://feedback.yaak.app/p/copy-body-only-works-on-first-click
2025-05-08 08:02:27 -07:00
Gregory Schier
d3b8a42180 Update README.md 2025-05-07 12:00:34 -07:00
Gregory Schier
95f39c514a Update README.md 2025-05-07 11:59:01 -07:00
hexchain
7cba082eb0 Allow building and running on aarch64 Linux (#206)
Co-authored-by: Haochen Tong <haochentong@bytedance.com>
2025-05-01 08:06:13 -07:00
Billzabob
3b9b320be2 Send cookies for introspection (#204) 2025-04-30 10:13:06 -07:00
Gregory Schier
18664975a9 Padding on encrypted input 2025-04-26 07:33:32 -07:00
172 changed files with 3600 additions and 3883 deletions

View File

@@ -65,7 +65,7 @@ jobs:
- name: install dependencies (windows only)
if: matrix.platform == 'windows-latest'
run: cargo install --force trusted-signing-cli
run: cargo install --force trusted-signing-cli --version 0.5.0
- name: Install NPM Dependencies
run: |

View File

@@ -3,7 +3,12 @@
Yaak is a desktop API client for interacting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC
APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
![screenshot](https://github.com/user-attachments/assets/f18e963f-0b68-4ecb-b8b8-cb71aa9aec02)
![366149288-f18e963f-0b68-4ecb-b8b8-cb71aa9aec02](https://github.com/user-attachments/assets/ca83b7ad-5708-411b-8faf-e36b365841a4)
## Contribution Policy
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.
## Feature Overview
@@ -14,6 +19,7 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
- ⛓️ 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/>
- 🛡️ Secure arbitrary text values with end-to-end encryption<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/>
@@ -21,17 +27,8 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
- 🔌 Create your own plugins for authentication, template tags, and more!<br/>
- 🛜 Configure a proxy to access firewall-blocked APIs
## Feedback and Bug Reports
## Useful Resources
All feedback, bug reports, questions, and feature requests should be reported to
[feedback.yaak.app](https://feedback.yaak.app).
## Community Projects
- [`yaak2postman`](https://github.com/BiteCraft/yaak2postman) CLI for converting Yaak data
exports to Postman-compatible collections
## Contribution Policy
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.
- [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://feedback.yaak.app/help)
- [Yaak vs Postman](https://yaak.app/blog/postman-alternative)

2285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,14 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, description: string, name: string, defaultAuthentication: ParentAuthentication, defaultHeaders: Array<HttpRequestHeader>, sortPriority: number, };
export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
@@ -22,6 +20,8 @@ export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type ParentAuthentication = { authentication: Record<string, any>, authenticationType: string | null, };
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, defaultAuthentication: ParentAuthentication, defaultHeaders: Array<HttpRequestHeader>, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -3,3 +3,7 @@ export type * from './themes';
export * from './bindings/gen_models';
export * from './bindings/gen_events';
// Some extras for utility
export type { PartialImportResources } from './plugins/ImporterPlugin';

View File

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

View File

@@ -1,6 +1,6 @@
import type { Context } from './Context';
export type FilterPluginResponse = { filtered: string };
type FilterPluginResponse = { filtered: string };
export type FilterPlugin = {
name: string;

View File

@@ -1,15 +1,21 @@
import { Environment, Folder, GrpcRequest, HttpRequest, Workspace } from '../bindings/gen_models';
import type { AtLeast } from '../helpers';
import { ImportResources } from '../bindings/gen_events';
import { AtLeast } from '../helpers';
import type { Context } from './Context';
type ImportPluginResponse = null | {
resources: {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
grpcRequests: AtLeast<GrpcRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
};
type RootFields = 'name' | 'id' | 'model';
type CommonFields = RootFields | 'workspaceId';
export type PartialImportResources = {
workspaces: Array<AtLeast<ImportResources['workspaces'][0], RootFields>>;
environments: Array<AtLeast<ImportResources['environments'][0], CommonFields>>;
folders: Array<AtLeast<ImportResources['folders'][0], CommonFields>>;
httpRequests: Array<AtLeast<ImportResources['httpRequests'][0], CommonFields>>;
grpcRequests: Array<AtLeast<ImportResources['grpcRequests'][0], CommonFields>>;
websocketRequests: Array<AtLeast<ImportResources['websocketRequests'][0], CommonFields>>;
};
export type ImportPluginResponse = null | {
resources: PartialImportResources;
};
export type ImporterPlugin = {

View File

@@ -9,12 +9,14 @@ const NODE_VERSION = 'v22.9.0';
// `${process.platform}_${process.arch}`
const MAC_ARM = 'darwin_arm64';
const MAC_X64 = 'darwin_x64';
const LNX_ARM = 'linux_arm64';
const LNX_X64 = 'linux_x64';
const WIN_X64 = 'win32_x64';
const URL_MAP = {
[MAC_ARM]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-darwin-arm64.tar.gz`,
[MAC_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-darwin-x64.tar.gz`,
[LNX_ARM]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz`,
[LNX_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz`,
[WIN_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`,
};
@@ -22,15 +24,17 @@ const URL_MAP = {
const SRC_BIN_MAP = {
[MAC_ARM]: `node-${NODE_VERSION}-darwin-arm64/bin/node`,
[MAC_X64]: `node-${NODE_VERSION}-darwin-x64/bin/node`,
[LNX_ARM]: `node-${NODE_VERSION}-linux-arm64/bin/node`,
[LNX_X64]: `node-${NODE_VERSION}-linux-x64/bin/node`,
[WIN_X64]: `node-${NODE_VERSION}-win-x64/node.exe`,
};
const DST_BIN_MAP = {
darwin_arm64: 'yaaknode-aarch64-apple-darwin',
darwin_x64: 'yaaknode-x86_64-apple-darwin',
linux_x64: 'yaaknode-x86_64-unknown-linux-gnu',
win32_x64: 'yaaknode-x86_64-pc-windows-msvc.exe',
[MAC_ARM]: 'yaaknode-aarch64-apple-darwin',
[MAC_X64]: 'yaaknode-x86_64-apple-darwin',
[LNX_ARM]: 'yaaknode-aarch64-unknown-linux-gnu',
[LNX_X64]: 'yaaknode-x86_64-unknown-linux-gnu',
[WIN_X64]: 'yaaknode-x86_64-pc-windows-msvc.exe',
};
const key = `${process.platform}_${process.env.YAAK_TARGET_ARCH ?? process.arch}`;

View File

@@ -9,12 +9,14 @@ const VERSION = '28.3';
// `${process.platform}_${process.arch}`
const MAC_ARM = 'darwin_arm64';
const MAC_X64 = 'darwin_x64';
const LNX_ARM = 'linux_arm64';
const LNX_X64 = 'linux_x64';
const WIN_X64 = 'win32_x64';
const URL_MAP = {
[MAC_ARM]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-osx-aarch_64.zip`,
[MAC_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-osx-x86_64.zip`,
[LNX_ARM]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-linux-aarch_64.zip`,
[LNX_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-linux-x86_64.zip`,
[WIN_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-win64.zip`,
};
@@ -22,6 +24,7 @@ const URL_MAP = {
const SRC_BIN_MAP = {
[MAC_ARM]: 'bin/protoc',
[MAC_X64]: 'bin/protoc',
[LNX_ARM]: 'bin/protoc',
[LNX_X64]: 'bin/protoc',
[WIN_X64]: 'bin/protoc.exe',
};
@@ -29,6 +32,7 @@ const SRC_BIN_MAP = {
const DST_BIN_MAP = {
[MAC_ARM]: 'yaakprotoc-aarch64-apple-darwin',
[MAC_X64]: 'yaakprotoc-x86_64-apple-darwin',
[LNX_ARM]: 'yaakprotoc-aarch64-unknown-linux-gnu',
[LNX_X64]: 'yaakprotoc-x86_64-unknown-linux-gnu',
[WIN_X64]: 'yaakprotoc-x86_64-pc-windows-msvc.exe',
};

View File

@@ -7,6 +7,7 @@
"*"
],
"permissions": [
"core:app:allow-identifier",
"core:event:allow-emit",
"core:event:allow-listen",
"core:event:allow-unlisten",

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN hide_window_controls BOOLEAN DEFAULT FALSE NOT NULL;

View File

@@ -0,0 +1,11 @@
-- There used to be sync code that skipped over environments because we didn't
-- want to sync potentially insecure data. With encryption, it is now possible
-- to sync environments securely. However, there were already sync states in the
-- DB that marked environments as "Synced". Running the sync code on these envs
-- would mark them as deleted by FS (exist in SyncState but not on FS).
--
-- To undo this mess, we have this migration to delete all environment-related
-- sync states so we can sync from a clean slate.
DELETE
FROM sync_states
WHERE model_id LIKE 'ev_%';

View File

@@ -0,0 +1,20 @@
-- Add a public column to represent whether an environment can be shared or exported
ALTER TABLE environments
ADD COLUMN public BOOLEAN DEFAULT FALSE;
-- Add a base column to represent whether an environment is a base or sub environment. We used to
-- do this with environment_id, but we need a more flexible solution now that envs can be optionally
-- synced. E.g., it's now possible to only import a sub environment from a different client without
-- its base environment "parent."
ALTER TABLE environments
ADD COLUMN base BOOLEAN DEFAULT FALSE;
-- SQLite doesn't support dynamic default values, so we update `base` based on the value of
-- environment_id.
UPDATE environments
SET base = TRUE
WHERE environment_id IS NULL;
-- Finally, we drop the old `environment_id` column that will no longer be used
ALTER TABLE environments
DROP COLUMN environment_id;

View File

@@ -0,0 +1,15 @@
-- Auth
ALTER TABLE workspaces
ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
ALTER TABLE folders
ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
ALTER TABLE workspaces
ADD COLUMN authentication_type TEXT;
ALTER TABLE folders
ADD COLUMN authentication_type TEXT;
-- Headers
ALTER TABLE workspaces
ADD COLUMN headers TEXT NOT NULL DEFAULT '[]';
ALTER TABLE folders
ADD COLUMN headers TEXT NOT NULL DEFAULT '[]';

View File

@@ -1,10 +1,15 @@
use std::collections::BTreeMap;
use crate::error::Result;
use KeyAndValueRef::{Ascii, Binary};
use tauri::{Manager, Runtime, WebviewWindow};
use yaak_grpc::{KeyAndValueRef, MetadataMap};
use yaak_models::models::GrpcRequest;
use yaak_models::query_manager::QueryManagerExt;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
use yaak_plugins::manager::PluginManager;
pub fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
let mut entries = BTreeMap::new();
for r in metadata.iter() {
match r {
@@ -14,3 +19,52 @@ pub fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
}
entries
}
pub(crate) async fn build_metadata<R: Runtime>(
window: &WebviewWindow<R>,
request: &GrpcRequest,
) -> Result<BTreeMap<String, String>> {
let plugin_manager = window.state::<PluginManager>();
let mut metadata = BTreeMap::new();
// Add the rest of metadata
let resolved_metadata = window.db().resolve_metadata_for_grpc_request(&request)?;
for h in resolved_metadata {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
metadata.insert(h.name, h.value);
}
let (authentication_type, authentication) =
window.db().resolve_auth_for_grpc_request(&request)?;
if let Some(auth_name) = authentication_type.clone() {
let auth = authentication.clone();
let plugin_req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(request.id.clone())),
values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(),
method: "POST".to_string(),
url: request.url.clone(),
headers: metadata
.iter()
.map(|(name, value)| HttpHeader {
name: name.to_string(),
value: value.to_string(),
})
.collect(),
};
let plugin_result =
plugin_manager.call_http_authentication(&window, &auth_name, plugin_req).await?;
for header in plugin_result.set_headers {
metadata.insert(header.name, header.value);
}
}
Ok(metadata)
}

View File

@@ -84,7 +84,7 @@ pub async fn send_http_request<R: Runtime>(
}
};
let mut url_string = request.url;
let mut url_string = request.url.clone();
url_string = ensure_proto(&url_string);
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
@@ -123,7 +123,12 @@ pub async fn send_http_request<R: Runtime>(
match settings.proxy {
Some(ProxySetting::Disabled) => client_builder = client_builder.no_proxy(),
Some(ProxySetting::Enabled { http, https, auth }) => {
Some(ProxySetting::Enabled {
http,
https,
auth,
disabled,
}) if !disabled => {
debug!("Using proxy http={http} https={https}");
let mut proxy = Proxy::custom(move |url| {
let http = if http.is_empty() { None } else { Some(http.to_owned()) };
@@ -143,7 +148,7 @@ pub async fn send_http_request<R: Runtime>(
client_builder = client_builder.proxy(proxy);
}
None => {} // Nothing to do for this one, as it is the default
_ => {} // Nothing to do for this one, as it is the default
}
// Add cookie store if specified
@@ -222,7 +227,9 @@ pub async fn send_http_request<R: Runtime>(
// );
// }
for h in request.headers.clone() {
let resolved_headers = window.db().resolve_headers_for_http_request(&request)?;
for h in resolved_headers {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
@@ -250,7 +257,7 @@ pub async fn send_http_request<R: Runtime>(
}
let request_body = request.body.clone();
if let Some(body_type) = &request.body_type {
if let Some(body_type) = &request.body_type.clone() {
if body_type == "graphql" {
let query = get_str_h(&request_body, "query");
let variables = get_str_h(&request_body, "variables");
@@ -371,7 +378,7 @@ pub async fn send_http_request<R: Runtime>(
};
}
// Set file path if it is not empty
// Set a file path if it is not empty
if !file_path.is_empty() {
let filename = PathBuf::from(file_path)
.file_name()
@@ -394,6 +401,15 @@ pub async fn send_http_request<R: Runtime>(
} else {
warn!("Unsupported body type: {}", body_type);
}
} else {
// No body set
let method = request.method.to_ascii_lowercase();
let is_body_method = method == "post" || method == "put" || method == "patch";
// Add Content-Length for methods that commonly accept a body because some servers
// will error if they don't receive it.
if is_body_method && !headers.contains_key("content-length") {
headers.insert("Content-Length", HeaderValue::from_static("0"));
}
}
// Add headers last, because previous steps may modify them
@@ -412,43 +428,53 @@ pub async fn send_http_request<R: Runtime>(
}
};
// Apply authentication
let (authentication_type, authentication) =
window.db().resolve_auth_for_http_request(&request)?;
if let Some(auth_name) = request.authentication_type.to_owned() {
let req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(request.id)),
values: serde_json::from_value(serde_json::to_value(&request.authentication).unwrap())
.unwrap(),
url: sendable_req.url().to_string(),
method: sendable_req.method().to_string(),
headers: sendable_req
.headers()
.iter()
.map(|(name, value)| HttpHeader {
name: name.to_string(),
value: value.to_str().unwrap_or_default().to_string(),
})
.collect(),
};
let auth_result = plugin_manager.call_http_authentication(&window, &auth_name, req).await;
let plugin_result = match auth_result {
Ok(r) => r,
Err(e) => {
return Ok(response_err(
&app_handle,
&*response.lock().await,
e.to_string(),
&update_source,
));
match authentication_type {
None => {
// No authentication found. Not even inherited
}
Some(authentication_type) if authentication_type == "none" => {
// Explicitly no authentication
}
Some(authentication_type) => {
let req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(request.id)),
values: serde_json::from_value(serde_json::to_value(&authentication).unwrap())
.unwrap(),
url: sendable_req.url().to_string(),
method: sendable_req.method().to_string(),
headers: sendable_req
.headers()
.iter()
.map(|(name, value)| HttpHeader {
name: name.to_string(),
value: value.to_str().unwrap_or_default().to_string(),
})
.collect(),
};
let auth_result =
plugin_manager.call_http_authentication(&window, &authentication_type, req).await;
let plugin_result = match auth_result {
Ok(r) => r,
Err(e) => {
return Ok(response_err(
&app_handle,
&*response.lock().await,
e.to_string(),
&update_source,
));
}
};
let headers = sendable_req.headers_mut();
for header in plugin_result.set_headers {
headers.insert(
HeaderName::from_str(&header.name).unwrap(),
HeaderValue::from_str(&header.value).unwrap(),
);
}
};
let headers = sendable_req.headers_mut();
for header in plugin_result.set_headers {
headers.insert(
HeaderName::from_str(&header.name).unwrap(),
HeaderValue::from_str(&header.value).unwrap(),
);
}
}

View File

@@ -1,7 +1,7 @@
extern crate core;
use crate::encoding::read_response_body;
use crate::error::Error::GenericError;
use crate::grpc::metadata_to_map;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -38,9 +38,9 @@ use yaak_models::util::{
BatchUpsertResult, UpdateSource, get_workspace_export_resources, maybe_gen_id, maybe_gen_id_opt,
};
use yaak_plugins::events::{
BootResponse, CallHttpAuthenticationRequest, CallHttpRequestActionRequest, FilterResponse,
BootResponse, CallHttpRequestActionRequest, FilterResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, HttpHeader, InternalEvent,
GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, InternalEvent,
InternalEventPayload, JsonPrimitive, PluginWindowContext, RenderPurpose,
};
use yaak_plugins::manager::PluginManager;
@@ -166,6 +166,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
.await?;
let uri = safe_uri(&req.url);
let metadata = build_metadata(&window, &req).await?;
Ok(grpc_handle
.lock()
@@ -174,6 +175,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
&req.id,
&uri,
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
)
.await
.map_err(|e| GenericError(e.to_string()))?)
@@ -186,7 +188,6 @@ async fn cmd_grpc_go<R: Runtime>(
proto_files: Vec<String>,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<String> {
let environment = match environment_id {
@@ -208,42 +209,7 @@ async fn cmd_grpc_go<R: Runtime>(
)
.await?;
let mut metadata = BTreeMap::new();
// Add the rest of metadata
for h in request.clone().metadata {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
metadata.insert(h.name, h.value);
}
if let Some(auth_name) = request.authentication_type.clone() {
let auth = request.authentication.clone();
let plugin_req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(request_id.to_string())),
values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(),
method: "POST".to_string(),
url: request.url.clone(),
headers: metadata
.iter()
.map(|(name, value)| HttpHeader {
name: name.to_string(),
value: value.to_string(),
})
.collect(),
};
let plugin_result =
plugin_manager.call_http_authentication(&window, &auth_name, plugin_req).await?;
for header in plugin_result.set_headers {
metadata.insert(header.name, header.value);
}
}
let metadata = build_metadata(&window, &request).await?;
let conn = app_handle.db().upsert_grpc_connection(
&GrpcConnection {
@@ -291,6 +257,7 @@ async fn cmd_grpc_go<R: Runtime>(
&request.clone().id,
uri.as_str(),
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
)
.await;
@@ -448,7 +415,7 @@ async fn cmd_grpc_go<R: Runtime>(
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
(true, true) => (
Some(
connection.streaming(&service, &method, in_msg_stream, metadata).await,
connection.streaming(&service, &method, in_msg_stream, &metadata).await,
),
None,
),
@@ -456,16 +423,16 @@ async fn cmd_grpc_go<R: Runtime>(
None,
Some(
connection
.client_streaming(&service, &method, in_msg_stream, metadata)
.client_streaming(&service, &method, in_msg_stream, &metadata)
.await,
),
),
(false, true) => (
Some(connection.server_streaming(&service, &method, &msg, metadata).await),
Some(connection.server_streaming(&service, &method, &msg, &metadata).await),
None,
),
(false, false) => {
(None, Some(connection.unary(&service, &method, &msg, metadata).await))
(None, Some(connection.unary(&service, &method, &msg, &metadata).await))
}
};
@@ -831,7 +798,6 @@ async fn cmd_import_data<R: Runtime>(
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.environment_id = maybe_gen_id_opt::<Environment>(v.environment_id, &mut id_map);
v
})
.collect();
@@ -951,10 +917,10 @@ async fn cmd_call_http_authentication_action<R: Runtime>(
auth_name: &str,
action_index: i32,
values: HashMap<String, JsonPrimitive>,
request_id: &str,
model_id: &str,
) -> YaakResult<()> {
Ok(plugin_manager
.call_http_authentication_action(&window, auth_name, action_index, values, request_id)
.call_http_authentication_action(&window, auth_name, action_index, values, model_id)
.await?)
}
@@ -985,10 +951,10 @@ async fn cmd_export_data<R: Runtime>(
app_handle: AppHandle<R>,
export_path: &str,
workspace_ids: Vec<&str>,
include_environments: bool,
include_private_environments: bool,
) -> YaakResult<()> {
let export_data =
get_workspace_export_resources(&app_handle, workspace_ids, include_environments).await?;
get_workspace_export_resources(&app_handle, workspace_ids, include_private_environments)?;
let f = File::options()
.create(true)
.truncate(true)

View File

@@ -8,6 +8,7 @@ use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use yaak_license::{LicenseCheckStatus, check_license};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
@@ -70,6 +71,13 @@ impl YaakNotifier {
self.last_check = SystemTime::now();
let license_check = match check_license(window).await? {
LicenseCheckStatus::PersonalUse { .. } => "personal".to_string(),
LicenseCheckStatus::CommercialUse => "commercial".to_string(),
LicenseCheckStatus::InvalidLicense => "invalid_license".to_string(),
LicenseCheckStatus::Trialing { .. } => "trialing".to_string(),
};
let settings = window.db().get_settings();
let num_launches = get_num_launches(app_handle).await;
let info = app_handle.package_info().clone();
let req = reqwest::Client::default()
@@ -77,6 +85,8 @@ impl YaakNotifier {
.query(&[
("version", info.version.to_string().as_str()),
("launches", num_launches.to_string().as_str()),
("installed", settings.created_at.format("%Y-%m-%d").to_string().as_str()),
("license", &license_check),
("platform", get_os()),
]);
let resp = req.send().await?;

View File

@@ -2,7 +2,7 @@ use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use yaak_http::apply_path_placeholders;
use yaak_models::models::{
Environment, GrpcMetadataEntry, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{parse_and_render, render_json_value_raw, TemplateCallback};
@@ -37,7 +37,7 @@ pub async fn render_grpc_request<T: TemplateCallback>(
let mut metadata = Vec::new();
for p in r.metadata.clone() {
metadata.push(GrpcMetadataEntry {
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: render(p.name.as_str(), vars, cb).await?,
value: render(p.value.as_str(), vars, cb).await?,

View File

@@ -32,6 +32,7 @@ var import_node_fs = require("node:fs");
async function getAccessToken(ctx, {
accessTokenUrl,
scope,
audience,
params,
grantType,
credentialsInBody,
@@ -56,6 +57,7 @@ async function getAccessToken(ctx, {
]
};
if (scope) httpRequest.body.form.push({ name: "scope", value: scope });
if (scope) httpRequest.body.form.push({ name: "audience", value: audience });
if (credentialsInBody) {
httpRequest.body.form.push({ name: "client_id", value: clientId });
httpRequest.body.form.push({ name: "client_secret", value: clientSecret });
@@ -64,10 +66,10 @@ async function getAccessToken(ctx, {
httpRequest.headers.push({ name: "Authorization", value });
}
const resp = await ctx.httpRequest.send({ httpRequest });
const body = resp.bodyPath ? (0, import_node_fs.readFileSync)(resp.bodyPath, "utf8") : "";
if (resp.status < 200 || resp.status >= 300) {
throw new Error("Failed to fetch access token with status=" + resp.status);
throw new Error("Failed to fetch access token with status=" + resp.status + " and body=" + body);
}
const body = (0, import_node_fs.readFileSync)(resp.bodyPath ?? "", "utf8");
let response;
try {
response = JSON.parse(body);
@@ -168,10 +170,10 @@ async function getOrRefreshAccessToken(ctx, contextId, {
await deleteToken(ctx, contextId);
return null;
}
const body = resp.bodyPath ? (0, import_node_fs2.readFileSync)(resp.bodyPath, "utf8") : "";
if (resp.status < 200 || resp.status >= 300) {
throw new Error("Failed to fetch access token with status=" + resp.status);
throw new Error("Failed to refresh access token with status=" + resp.status + " and body=" + body);
}
const body = (0, import_node_fs2.readFileSync)(resp.bodyPath ?? "", "utf8");
let response;
try {
response = JSON.parse(body);
@@ -201,6 +203,7 @@ async function getAuthorizationCode(ctx, contextId, {
redirectUri,
scope,
state,
audience,
credentialsInBody,
pkce
}) {
@@ -220,6 +223,7 @@ async function getAuthorizationCode(ctx, contextId, {
if (redirectUri) authorizationUrl.searchParams.set("redirect_uri", redirectUri);
if (scope) authorizationUrl.searchParams.set("scope", scope);
if (state) authorizationUrl.searchParams.set("state", state);
if (audience) authorizationUrl.searchParams.set("audience", audience);
if (pkce) {
const verifier = pkce.codeVerifier || createPkceCodeVerifier();
const challengeMethod = pkce.challengeMethod || DEFAULT_PKCE_METHOD;
@@ -256,6 +260,7 @@ async function getAuthorizationCode(ctx, contextId, {
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: "code", value: code },
@@ -291,6 +296,7 @@ async function getClientCredentials(ctx, contextId, {
clientId,
clientSecret,
scope,
audience,
credentialsInBody
}) {
const token = await getToken(ctx, contextId);
@@ -299,6 +305,7 @@ async function getClientCredentials(ctx, contextId, {
const response = await getAccessToken(ctx, {
grantType: "client_credentials",
accessTokenUrl,
audience,
clientId,
clientSecret,
scope,
@@ -315,7 +322,8 @@ function getImplicit(ctx, contextId, {
clientId,
redirectUri,
scope,
state
state,
audience
}) {
return new Promise(async (resolve, reject) => {
const token = await getToken(ctx, contextId);
@@ -327,6 +335,7 @@ function getImplicit(ctx, contextId, {
if (redirectUri) authorizationUrl.searchParams.set("redirect_uri", redirectUri);
if (scope) authorizationUrl.searchParams.set("scope", scope);
if (state) authorizationUrl.searchParams.set("state", state);
if (audience) authorizationUrl.searchParams.set("audience", audience);
if (responseType.includes("id_token")) {
authorizationUrl.searchParams.set("nonce", String(Math.floor(Math.random() * 9999999999999) + 1));
}
@@ -366,6 +375,7 @@ async function getPassword(ctx, contextId, {
username,
password,
credentialsInBody,
audience,
scope
}) {
const token = await getOrRefreshAccessToken(ctx, contextId, {
@@ -383,6 +393,7 @@ async function getPassword(ctx, contextId, {
clientId,
clientSecret,
scope,
audience,
grantType: "password",
credentialsInBody,
params: [
@@ -530,6 +541,12 @@ var plugin = {
optional: true,
dynamic: hiddenIfNot(["authorization_code", "implicit"])
},
{
type: "text",
name: "audience",
label: "Audience",
optional: true
},
{
type: "checkbox",
name: "usePkce",
@@ -635,6 +652,7 @@ var plugin = {
clientSecret: stringArg(values, "clientSecret"),
redirectUri: stringArgOrNull(values, "redirectUri"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
state: stringArgOrNull(values, "state"),
credentialsInBody,
pkce: values.usePkce ? {
@@ -650,6 +668,7 @@ var plugin = {
redirectUri: stringArgOrNull(values, "redirectUri"),
responseType: stringArg(values, "responseType"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
state: stringArgOrNull(values, "state")
});
} else if (grantType === "client_credentials") {
@@ -659,6 +678,7 @@ var plugin = {
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
credentialsInBody
});
} else if (grantType === "password") {
@@ -670,6 +690,7 @@ var plugin = {
username: stringArg(values, "username"),
password: stringArg(values, "password"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
credentialsInBody
});
} else {

View File

@@ -542,20 +542,23 @@ function pairsToDataParameters(keyedPairs) {
}
for (const p of pairs) {
if (typeof p !== "string") continue;
const [name, value] = p.split("=");
if (p.startsWith("@")) {
dataParameters.push({
name: name ?? "",
value: "",
filePath: p.slice(1),
enabled: true
});
} else {
dataParameters.push({
name: name ?? "",
value: flagName === "data-urlencode" ? encodeURIComponent(value ?? "") : value ?? "",
enabled: true
});
let params = p.split("&");
for (const param of params) {
const [name, value] = param.split("=");
if (param.startsWith("@")) {
dataParameters.push({
name: name ?? "",
value: "",
filePath: param.slice(1),
enabled: true
});
} else {
dataParameters.push({
name: name ?? "",
value: flagName === "data-urlencode" ? encodeURIComponent(value ?? "") : value ?? "",
enabled: true
});
}
}
}
}

View File

@@ -7296,35 +7296,48 @@ __export(src_exports, {
});
module.exports = __toCommonJS(src_exports);
var import_yaml = __toESM(require_dist());
var plugin = {
importer: {
name: "Insomnia",
description: "Import Insomnia workspaces",
onImport(_ctx, args) {
return convertInsomnia(args.text);
}
// src/common.ts
function convertSyntax(variable) {
if (!isJSString(variable)) return variable;
return variable.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}");
}
function isJSObject(obj) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
function isJSString(obj) {
return Object.prototype.toString.call(obj) === "[object String]";
}
function convertId(id) {
if (id.startsWith("GENERATE_ID::")) {
return id;
}
};
function convertInsomnia(contents) {
let parsed;
try {
parsed = JSON.parse(contents);
} catch (e) {
return `GENERATE_ID::${id}`;
}
function deleteUndefinedAttrs(obj) {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs);
} else if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== void 0).map(([k, v]) => [k, deleteUndefinedAttrs(v)])
);
} else {
return obj;
}
try {
parsed = parsed ?? import_yaml.default.parse(contents);
} catch (e) {
}
if (!isJSObject(parsed)) return;
if (!Array.isArray(parsed.resources)) return;
}
// src/v4.ts
function convertInsomniaV4(parsed) {
if (!Array.isArray(parsed.resources)) return null;
const resources = {
workspaces: [],
httpRequests: [],
grpcRequests: [],
environments: [],
folders: []
folders: [],
grpcRequests: [],
httpRequests: [],
websocketRequests: [],
workspaces: []
};
const workspacesToImport = parsed.resources.filter(isWorkspace);
const workspacesToImport = parsed.resources.filter((r) => isJSObject(r) && r._type === "workspace");
for (const w of workspacesToImport) {
resources.workspaces.push({
id: convertId(w._id),
@@ -7335,25 +7348,25 @@ function convertInsomnia(contents) {
description: w.description || void 0
});
const environmentsToImport = parsed.resources.filter(
(r) => isEnvironment(r)
(r) => isJSObject(r) && r._type === "environment"
);
resources.environments.push(
...environmentsToImport.map((r) => importEnvironment(r, w._id))
);
const nextFolder = (parentId) => {
const children = parsed.resources.filter((r) => r.parentId === parentId);
let sortPriority = 0;
for (const child of children) {
if (isRequestGroup(child)) {
if (!isJSObject(child)) continue;
if (child._type === "request_group") {
resources.folders.push(importFolder(child, w._id));
nextFolder(child._id);
} else if (isHttpRequest(child)) {
} else if (child._type === "request") {
resources.httpRequests.push(
importHttpRequest(child, w._id, sortPriority++)
importHttpRequest(child, w._id)
);
} else if (isGrpcRequest(child)) {
} else if (child._type === "grpc_request") {
resources.grpcRequests.push(
importGrpcRequest(child, w._id, sortPriority++)
importGrpcRequest(child, w._id)
);
}
}
@@ -7364,62 +7377,9 @@ function convertInsomnia(contents) {
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return { resources: deleteUndefinedAttrs(resources) };
return { resources };
}
function importEnvironment(e, workspaceId) {
return {
id: convertId(e._id),
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : void 0,
updatedAt: e.updated ? new Date(e.updated).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
environmentId: e.parentId === workspaceId ? null : convertId(e.parentId),
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`
}))
};
}
function importFolder(f, workspaceId) {
return {
id: convertId(f._id),
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : void 0,
updatedAt: f.updated ? new Date(f.updated).toISOString().replace("Z", "") : void 0,
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
workspaceId: convertId(workspaceId),
description: f.description || void 0,
model: "folder",
name: f.name
};
}
function importGrpcRequest(r, workspaceId, sortPriority = 0) {
const parts = r.protoMethodName.split("/").filter((p) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
id: convertId(r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : void 0,
updatedAt: r.updated ? new Date(r.updated).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: "grpc_request",
sortPriority,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? "",
metadata: (r.metadata ?? []).map((h) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? ""
})).filter(({ name, value }) => name !== "" || value !== "")
};
}
function importHttpRequest(r, workspaceId, sortPriority = 0) {
function importHttpRequest(r, workspaceId) {
let bodyType = null;
let body = {};
if (r.body.mimeType === "application/octet-stream") {
@@ -7466,13 +7426,13 @@ function importHttpRequest(r, workspaceId, sortPriority = 0) {
};
}
return {
id: convertId(r._id),
id: convertId(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : void 0,
updatedAt: r.updated ? new Date(r.updated).toISOString().replace("Z", "") : void 0,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: "http_request",
sortPriority,
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
@@ -7488,47 +7448,309 @@ function importHttpRequest(r, workspaceId, sortPriority = 0) {
})).filter(({ name, value }) => name !== "" || value !== "")
};
}
function convertSyntax(variable) {
if (!isJSString(variable)) return variable;
return variable.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}");
function importGrpcRequest(r, workspaceId) {
const parts = r.protoMethodName.split("/").filter((p) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
id: convertId(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : void 0,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: "grpc_request",
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? "",
metadata: (r.metadata ?? []).map((h) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? ""
})).filter(({ name, value }) => name !== "" || value !== "")
};
}
function isWorkspace(obj) {
return isJSObject(obj) && obj._type === "workspace";
function importFolder(f, workspaceId) {
return {
id: convertId(f._id),
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : void 0,
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : void 0,
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
workspaceId: convertId(workspaceId),
description: f.description || void 0,
model: "folder",
name: f.name
};
}
function isRequestGroup(obj) {
return isJSObject(obj) && obj._type === "request_group";
function importEnvironment(e, workspaceId, isParent) {
return {
id: convertId(e._id),
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : void 0,
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
// @ts-ignore
sortPriority: e.metaSortKey,
// Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`
}))
};
}
function isHttpRequest(obj) {
return isJSObject(obj) && obj._type === "request";
// src/v5.ts
function convertInsomniaV5(parsed) {
if (!Array.isArray(parsed.collection)) return null;
const resources = {
environments: [],
folders: [],
grpcRequests: [],
httpRequests: [],
websocketRequests: [],
workspaces: []
};
const meta = parsed.meta ?? {};
resources.workspaces.push({
id: convertId(meta.id ?? "collection"),
createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : void 0,
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : void 0,
model: "workspace",
name: parsed.name,
description: meta.description || void 0
});
resources.environments.push(
importEnvironment2(parsed.environments, meta.id, true),
...(parsed.environments.subEnvironments ?? []).map((r) => importEnvironment2(r, meta.id))
);
const nextFolder = (children, parentId) => {
for (const child of children ?? []) {
if (!isJSObject(child)) continue;
if (Array.isArray(child.children)) {
resources.folders.push(importFolder2(child, meta.id, parentId));
nextFolder(child.children, child.meta.id);
} else if (child.method) {
resources.httpRequests.push(
importHttpRequest2(child, meta.id, parentId)
);
} else if (child.protoFileId) {
resources.grpcRequests.push(
importGrpcRequest2(child, meta.id, parentId)
);
} else if (child.url) {
resources.websocketRequests.push(
importWebsocketRequest(child, meta.id, parentId)
);
}
}
};
nextFolder(parsed.collection ?? [], meta.id);
resources.httpRequests = resources.httpRequests.filter(Boolean);
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return { resources };
}
function isGrpcRequest(obj) {
return isJSObject(obj) && obj._type === "grpc_request";
}
function isEnvironment(obj) {
return isJSObject(obj) && obj._type === "environment";
}
function isJSObject(obj) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
function isJSString(obj) {
return Object.prototype.toString.call(obj) === "[object String]";
}
function convertId(id) {
if (id.startsWith("GENERATE_ID::")) {
return id;
function importHttpRequest2(r, workspaceId, parentId) {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
let bodyType = null;
let body = {};
if (r.body.mimeType === "application/octet-stream") {
bodyType = "binary";
body = { filePath: r.body.fileName ?? "" };
} else if (r.body?.mimeType === "application/x-www-form-urlencoded") {
bodyType = "application/x-www-form-urlencoded";
body = {
form: (r.body.params ?? []).map((p) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? ""
}))
};
} else if (r.body?.mimeType === "multipart/form-data") {
bodyType = "multipart/form-data";
body = {
form: (r.body.params ?? []).map((p) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? "",
file: p.fileName ?? null
}))
};
} else if (r.body?.mimeType === "application/graphql") {
bodyType = "graphql";
body = { text: convertSyntax(r.body.text ?? "") };
} else if (r.body?.mimeType === "application/json") {
bodyType = "application/json";
body = { text: convertSyntax(r.body.text ?? "") };
}
return `GENERATE_ID::${id}`;
return {
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
model: "http_request",
name: r.name,
description: r.meta?.description || void 0,
url: convertSyntax(r.url),
body,
bodyType,
method: r.method,
...importHeaders(r),
...importAuthentication(r)
};
}
function deleteUndefinedAttrs(obj) {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs);
} else if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== void 0).map(([k, v]) => [k, deleteUndefinedAttrs(v)])
);
} else {
return obj;
function importGrpcRequest2(r, workspaceId, parentId) {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
const parts = r.protoMethodName.split("/").filter((p) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
model: "grpc_request",
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? "",
metadata: (r.metadata ?? []).map((h) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? ""
})).filter(({ name, value }) => name !== "" || value !== "")
};
}
function importWebsocketRequest(r, workspaceId, parentId) {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
return {
model: "websocket_request",
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || void 0,
url: convertSyntax(r.url),
message: r.body?.text ?? "",
...importHeaders(r),
...importAuthentication(r)
};
}
function importHeaders(r) {
const headers = (r.headers ?? []).map((h) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? ""
})).filter(({ name, value }) => name !== "" || value !== "");
return { headers };
}
function importAuthentication(r) {
let authenticationType = null;
let authentication = {};
if (r.authentication?.type === "bearer") {
authenticationType = "bearer";
authentication = {
token: convertSyntax(r.authentication.token)
};
} else if (r.authentication?.type === "basic") {
authenticationType = "basic";
authentication = {
username: convertSyntax(r.authentication.username),
password: convertSyntax(r.authentication.password)
};
}
return { authenticationType, authentication };
}
function importFolder2(f, workspaceId, parentId) {
const id = f.meta?.id ?? f._id;
const created = f.meta?.created ?? f.created;
const updated = f.meta?.modified ?? f.updated;
const sortKey = f.meta?.sortKey ?? f.sortKey;
return {
model: "folder",
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
workspaceId: convertId(workspaceId),
description: f.description || void 0,
name: f.name
};
}
function importEnvironment2(e, workspaceId, isParent) {
const id = e.meta?.id ?? e._id;
const created = e.meta?.created ?? e.created;
const updated = e.meta?.modified ?? e.updated;
const sortKey = e.meta?.sortKey ?? e.sortKey;
return {
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : void 0,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : void 0,
workspaceId: convertId(workspaceId),
public: !e.isPrivate,
// @ts-ignore
sortPriority: sortKey,
// Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`
}))
};
}
// src/index.ts
var plugin = {
importer: {
name: "Insomnia",
description: "Import Insomnia workspaces",
async onImport(_ctx, args) {
return convertInsomnia(args.text);
}
}
};
function convertInsomnia(contents) {
let parsed;
try {
parsed = JSON.parse(contents);
} catch (e) {
}
try {
parsed = parsed ?? import_yaml.default.parse(contents);
} catch (e) {
}
if (!isJSObject(parsed)) return null;
const result = convertInsomniaV5(parsed) ?? convertInsomniaV4(parsed);
return deleteUndefinedAttrs(result);
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {

View File

@@ -69,6 +69,12 @@ function migrateImport(contents) {
}
}
}
for (const environment of parsed.resources.environments ?? []) {
if ("environmentId" in environment) {
environment.base = environment.environmentId == null;
delete environment.environmentId;
}
}
return { resources: parsed.resources };
}
function isJSObject(obj) {

View File

@@ -38,7 +38,7 @@ pub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key<XChaCha20Poly1305>) ->
let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?;
let cipher = XChaCha20Poly1305::new(&key);
cipher.decrypt(nonce.into(), ciphered_data).map_err(|_| DecryptionError)
cipher.decrypt(nonce.into(), ciphered_data).map_err(|_e| DecryptionError)
}
#[cfg(test)]

View File

@@ -16,13 +16,16 @@ pub enum Error {
#[error("Incorrect workspace key")]
IncorrectWorkspaceKey,
#[error("Failed to decrypt workspace key: {0}")]
WorkspaceKeyDecryptionError(String),
#[error("Crypto IO error: {0}")]
IoError(#[from] io::Error),
#[error("Failed to encrypt")]
#[error("Failed to encrypt data")]
EncryptionError,
#[error("Failed to decrypt")]
#[error("Failed to decrypt data")]
DecryptionError,
#[error("Invalid encrypted data")]

View File

@@ -1,4 +1,6 @@
use crate::error::Error::{GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey};
use crate::error::Error::{
GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey, WorkspaceKeyDecryptionError,
};
use crate::error::{Error, Result};
use crate::master_key::MasterKey;
use crate::workspace_key::WorkspaceKey;
@@ -149,8 +151,10 @@ impl EncryptionManager {
let mkey = self.get_master_key()?;
let decoded_key = BASE64_STANDARD
.decode(key.encrypted_key)
.map_err(|e| GenericError(format!("Failed to decode workspace key {e:?}")))?;
let raw_key = mkey.decrypt(decoded_key.as_slice())?;
.map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;
let raw_key = mkey
.decrypt(decoded_key.as_slice())
.map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;
info!("Got existing workspace key for {workspace_id}");
let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice());

View File

@@ -1,14 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
@@ -20,4 +18,4 @@ export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environ
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -1,13 +1,15 @@
use crate::manager::decorate_req;
use crate::transport::get_transport;
use async_recursion::async_recursion;
use hyper_rustls::HttpsConnector;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use log::debug;
use std::collections::BTreeMap;
use tokio_stream::StreamExt;
use tonic::Request;
use tonic::body::BoxBody;
use tonic::transport::Uri;
use tonic::Request;
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
use tonic_reflection::pb::v1::{
@@ -44,6 +46,7 @@ impl AutoReflectionClient {
pub async fn send_reflection_request(
&mut self,
message: MessageRequest,
metadata: &BTreeMap<String, String>,
) -> Result<MessageResponse, String> {
let reflection_request = ServerReflectionRequest {
host: "".into(), // Doesn't matter
@@ -51,7 +54,9 @@ impl AutoReflectionClient {
};
if self.use_v1alpha {
let request = Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));
let mut request = Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));
decorate_req(metadata, &mut request).map_err(|e| e.to_string())?;
self.client_v1alpha
.server_reflection_info(request)
.await
@@ -70,7 +75,9 @@ impl AutoReflectionClient {
.ok_or("No reflection response".to_string())
.map(|resp| to_v1_msg_response(resp))
} else {
let request = Request::new(tokio_stream::once(reflection_request));
let mut request = Request::new(tokio_stream::once(reflection_request));
decorate_req(metadata, &mut request).map_err(|e| e.to_string())?;
let resp = self.client_v1.server_reflection_info(request).await;
match resp {
Ok(r) => Ok(r),
@@ -79,7 +86,7 @@ impl AutoReflectionClient {
// If v1 fails, change to v1alpha and try again
debug!("gRPC schema reflection falling back to v1alpha");
self.use_v1alpha = true;
return self.send_reflection_request(message).await;
return self.send_reflection_request(message, metadata).await;
}
_ => Err(e),
},

View File

@@ -1,16 +1,256 @@
use prost_reflect::{DescriptorPool, MessageDescriptor};
use prost_types::field_descriptor_proto;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use prost_reflect::{DescriptorPool, FieldDescriptor, MessageDescriptor};
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default, Serialize, Deserialize)]
pub fn message_to_json_schema(_: &DescriptorPool, root_msg: MessageDescriptor) -> JsonSchemaEntry {
JsonSchemaGenerator::generate_json_schema(root_msg)
}
struct JsonSchemaGenerator {
msg_mapping: HashMap<String, JsonSchemaEntry>,
}
impl JsonSchemaGenerator {
pub fn new() -> Self {
JsonSchemaGenerator {
msg_mapping: HashMap::new(),
}
}
pub fn generate_json_schema(msg: MessageDescriptor) -> JsonSchemaEntry {
let generator = JsonSchemaGenerator::new();
generator.scan_root(msg)
}
fn add_message(&mut self, msg: &MessageDescriptor) {
let name = msg.full_name().to_string();
if self.msg_mapping.contains_key(&name) {
return;
}
self.msg_mapping.insert(name.clone(), JsonSchemaEntry::object());
}
pub fn scan_root(mut self, root_msg: MessageDescriptor) -> JsonSchemaEntry {
self.init_structure(root_msg.clone());
self.fill_properties(root_msg.clone());
let mut root = self.msg_mapping.remove(root_msg.full_name()).unwrap();
if self.msg_mapping.len() > 0 {
root.defs = Some(self.msg_mapping);
}
root
}
fn fill_properties(&mut self, root_msg: MessageDescriptor) {
let root_name = root_msg.full_name().to_string();
let mut visited = HashSet::new();
let mut msg_queue = VecDeque::new();
msg_queue.push_back(root_msg);
while !msg_queue.is_empty() {
let msg = msg_queue.pop_front().unwrap();
let msg_name = msg.full_name();
if visited.contains(msg_name) {
continue;
}
visited.insert(msg_name.to_string());
let entry = self.msg_mapping.get_mut(msg_name).unwrap();
for field in msg.fields() {
let field_name = field.name().to_string();
if matches!(field.cardinality(), prost_reflect::Cardinality::Required) {
entry.add_required(field_name.clone());
}
if let Some(oneof) = field.containing_oneof() {
for oneof_field in oneof.fields() {
if let Some(fm) = is_message_field(&oneof_field) {
msg_queue.push_back(fm);
}
entry.add_property(
oneof_field.name().to_string(),
field_to_type_or_ref(&root_name, oneof_field),
);
}
continue;
}
let (field_type, nest_msg) = {
if let Some(fm) = is_message_field(&field) {
if field.is_list() {
// repeated message type
(
JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)),
Some(fm),
)
} else if field.is_map() {
let value_field = fm.get_field_by_name("value").unwrap();
if let Some(fm) = is_message_field(&value_field) {
(
JsonSchemaEntry::map(field_to_type_or_ref(
&root_name,
value_field,
)),
Some(fm),
)
} else {
(
JsonSchemaEntry::map(field_to_type_or_ref(
&root_name,
value_field,
)),
None,
)
}
} else {
(field_to_type_or_ref(&root_name, field), Some(fm))
}
} else {
if field.is_list() {
// repeated scalar type
(JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)), None)
} else {
(field_to_type_or_ref(&root_name, field), None)
}
}
};
if let Some(fm) = nest_msg {
msg_queue.push_back(fm);
}
entry.add_property(field_name, field_type);
}
}
}
fn init_structure(&mut self, root_msg: MessageDescriptor) {
let mut visited = HashSet::new();
let mut msg_queue = VecDeque::new();
msg_queue.push_back(root_msg.clone());
// level traversal, to make sure all message type is defined before used
while !msg_queue.is_empty() {
let msg = msg_queue.pop_front().unwrap();
let name = msg.full_name();
if visited.contains(name) {
continue;
}
visited.insert(name.to_string());
self.add_message(&msg);
for child in msg.child_messages() {
if child.is_map_entry() {
// for field with map<key, value> type, there will be a child message type *Entry generated
// just skip it
continue;
}
self.add_message(&child);
msg_queue.push_back(child);
}
for field in msg.fields() {
if let Some(oneof) = field.containing_oneof() {
for oneof_field in oneof.fields() {
if let Some(fm) = is_message_field(&oneof_field) {
self.add_message(&fm);
msg_queue.push_back(fm);
}
}
continue;
}
if field.is_map() {
// key is always scalar type, so no need to process
// value can be any type, so need to unpack value type
let map_field_msg = is_message_field(&field).unwrap();
let map_value_field = map_field_msg.get_field_by_name("value").unwrap();
if let Some(value_fm) = is_message_field(&map_value_field) {
self.add_message(&value_fm);
msg_queue.push_back(value_fm);
}
continue;
}
if let Some(fm) = is_message_field(&field) {
self.add_message(&fm);
msg_queue.push_back(fm);
}
}
}
}
}
fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEntry {
match field.kind() {
prost_reflect::Kind::Bool => JsonSchemaEntry::boolean(),
prost_reflect::Kind::Double => JsonSchemaEntry::number("double"),
prost_reflect::Kind::Float => JsonSchemaEntry::number("float"),
prost_reflect::Kind::Int32 => JsonSchemaEntry::number("int32"),
prost_reflect::Kind::Int64 => JsonSchemaEntry::string_with_format("int64"),
prost_reflect::Kind::Uint32 => JsonSchemaEntry::number("int64"),
prost_reflect::Kind::Uint64 => JsonSchemaEntry::string_with_format("uint64"),
prost_reflect::Kind::Sint32 => JsonSchemaEntry::number("sint32"),
prost_reflect::Kind::Sint64 => JsonSchemaEntry::string_with_format("sint64"),
prost_reflect::Kind::Fixed32 => JsonSchemaEntry::number("int64"),
prost_reflect::Kind::Fixed64 => JsonSchemaEntry::string_with_format("fixed64"),
prost_reflect::Kind::Sfixed32 => JsonSchemaEntry::number("sfixed32"),
prost_reflect::Kind::Sfixed64 => JsonSchemaEntry::string_with_format("sfixed64"),
prost_reflect::Kind::String => JsonSchemaEntry::string(),
prost_reflect::Kind::Bytes => JsonSchemaEntry::string_with_format("byte"),
prost_reflect::Kind::Enum(enums) => {
let values = enums.values().map(|v| v.name().to_string()).collect::<Vec<_>>();
JsonSchemaEntry::enums(values)
}
prost_reflect::Kind::Message(fm) => {
let field_type_full_name = fm.full_name();
match field_type_full_name {
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
"google.protobuf.Duration" => JsonSchemaEntry::string(),
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
"google.protobuf.UInt32Value" => JsonSchemaEntry::string_with_format("int64"),
"google.protobuf.Int64Value" => JsonSchemaEntry::string_with_format("int64"),
"google.protobuf.UInt64Value" => JsonSchemaEntry::string_with_format("uint64"),
"google.protobuf.FloatValue" => JsonSchemaEntry::number("float"),
"google.protobuf.DoubleValue" => JsonSchemaEntry::number("double"),
"google.protobuf.BoolValue" => JsonSchemaEntry::boolean(),
"google.protobuf.Empty" => JsonSchemaEntry::default(),
"google.protobuf.Struct" => JsonSchemaEntry::object(),
"google.protobuf.ListValue" => JsonSchemaEntry::array(JsonSchemaEntry::default()),
"google.protobuf.NullValue" => JsonSchemaEntry::null(),
name @ _ if name == root_name => JsonSchemaEntry::root_reference(),
_ => JsonSchemaEntry::reference(fm.full_name()),
}
}
}
}
fn is_message_field(field: &FieldDescriptor) -> Option<MessageDescriptor> {
match field.kind() {
prost_reflect::Kind::Message(m) => Some(m),
_ => None,
}
}
#[derive(Default, serde::Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct JsonSchemaEntry {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(rename = "type")]
type_: JsonType,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
type_: Option<JsonType>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
@@ -21,15 +261,115 @@ pub struct JsonSchemaEntry {
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
enum_: Option<Vec<String>>,
/// Don't allow any other properties in the object
additional_properties: bool,
// for map type
#[serde(skip_serializing_if = "Option::is_none")]
additional_properties: Option<Box<JsonSchemaEntry>>,
/// Set all properties to required
// Set all properties to required
#[serde(skip_serializing_if = "Option::is_none")]
required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
items: Option<Box<JsonSchemaEntry>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "$defs")]
defs: Option<HashMap<String, JsonSchemaEntry>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "$ref")]
ref_: Option<String>,
}
impl JsonSchemaEntry {
pub fn add_property(&mut self, name: String, entry: JsonSchemaEntry) {
if self.properties.is_none() {
self.properties = Some(HashMap::new());
}
self.properties.as_mut().unwrap().insert(name, entry);
}
pub fn add_required(&mut self, name: String) {
if self.required.is_none() {
self.required = Some(Vec::new());
}
self.required.as_mut().unwrap().push(name);
}
}
impl JsonSchemaEntry {
pub fn object() -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Object),
..Default::default()
}
}
pub fn boolean() -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Boolean),
..Default::default()
}
}
pub fn number<S: Into<String>>(format: S) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Number),
format: Some(format.into()),
..Default::default()
}
}
pub fn string() -> Self {
JsonSchemaEntry {
type_: Some(JsonType::String),
..Default::default()
}
}
pub fn string_with_format<S: Into<String>>(format: S) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::String),
format: Some(format.into()),
..Default::default()
}
}
pub fn reference<S: AsRef<str>>(ref_: S) -> Self {
JsonSchemaEntry {
ref_: Some(format!("#/$defs/{}", ref_.as_ref())),
..Default::default()
}
}
pub fn root_reference() -> Self{
JsonSchemaEntry {
ref_: Some("#".to_string()),
..Default::default()
}
}
pub fn array(item: JsonSchemaEntry) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Array),
items: Some(Box::new(item)),
..Default::default()
}
}
pub fn enums(enums: Vec<String>) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::String),
enum_: Some(enums),
..Default::default()
}
}
pub fn map(value_type: JsonSchemaEntry) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Object),
additional_properties: Some(Box::new(value_type)),
..Default::default()
}
}
pub fn null() -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Null),
..Default::default()
}
}
}
enum JsonType {
@@ -49,7 +389,7 @@ impl Default for JsonType {
}
impl serde::Serialize for JsonType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
@@ -64,116 +404,3 @@ impl serde::Serialize for JsonType {
}
}
}
impl<'de> serde::Deserialize<'de> for JsonType {
fn deserialize<D>(deserializer: D) -> Result<JsonType, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"string" => Ok(JsonType::String),
"number" => Ok(JsonType::Number),
"object" => Ok(JsonType::Object),
"array" => Ok(JsonType::Array),
"boolean" => Ok(JsonType::Boolean),
"null" => Ok(JsonType::Null),
_ => Ok(JsonType::_UNKNOWN),
}
}
}
pub fn message_to_json_schema(
pool: &DescriptorPool,
message: MessageDescriptor,
) -> JsonSchemaEntry {
let mut schema = JsonSchemaEntry {
title: Some(message.name().to_string()),
type_: JsonType::Object, // Messages are objects
..Default::default()
};
let mut properties = HashMap::new();
message.fields().for_each(|f| match f.kind() {
prost_reflect::Kind::Message(m) => {
properties.insert(f.name().to_string(), message_to_json_schema(pool, m));
}
prost_reflect::Kind::Enum(e) => {
properties.insert(
f.name().to_string(),
JsonSchemaEntry {
type_: map_proto_type_to_json_type(f.field_descriptor_proto().r#type()),
enum_: Some(e.values().map(|v| v.name().to_string()).collect::<Vec<_>>()),
..Default::default()
},
);
}
_ => {
// TODO: Handle repeated label
match f.field_descriptor_proto().label() {
field_descriptor_proto::Label::Repeated => {
// TODO: Handle more complex repeated types. This just handles primitives for now
properties.insert(
f.name().to_string(),
JsonSchemaEntry {
type_: JsonType::Array,
items: Some(Box::new(JsonSchemaEntry {
type_: map_proto_type_to_json_type(
f.field_descriptor_proto().r#type(),
),
..Default::default()
})),
..Default::default()
},
);
}
_ => {
// Regular JSON field
properties.insert(
f.name().to_string(),
JsonSchemaEntry {
type_: map_proto_type_to_json_type(f.field_descriptor_proto().r#type()),
..Default::default()
},
);
}
};
}
});
schema.properties = Some(properties);
// All proto 3 fields are optional, so maybe we could
// make this a setting?
// schema.required = Some(
// message
// .fields()
// .map(|f| f.name().to_string())
// .collect::<Vec<_>>(),
// );
schema
}
fn map_proto_type_to_json_type(proto_type: field_descriptor_proto::Type) -> JsonType {
match proto_type {
field_descriptor_proto::Type::Double => JsonType::Number,
field_descriptor_proto::Type::Float => JsonType::Number,
field_descriptor_proto::Type::Int64 => JsonType::Number,
field_descriptor_proto::Type::Uint64 => JsonType::Number,
field_descriptor_proto::Type::Int32 => JsonType::Number,
field_descriptor_proto::Type::Fixed64 => JsonType::Number,
field_descriptor_proto::Type::Fixed32 => JsonType::Number,
field_descriptor_proto::Type::Bool => JsonType::Boolean,
field_descriptor_proto::Type::String => JsonType::String,
field_descriptor_proto::Type::Group => JsonType::_UNKNOWN,
field_descriptor_proto::Type::Message => JsonType::Object,
field_descriptor_proto::Type::Bytes => JsonType::String,
field_descriptor_proto::Type::Uint32 => JsonType::Number,
field_descriptor_proto::Type::Enum => JsonType::String,
field_descriptor_proto::Type::Sfixed32 => JsonType::Number,
field_descriptor_proto::Type::Sfixed64 => JsonType::Number,
field_descriptor_proto::Type::Sint32 => JsonType::Number,
field_descriptor_proto::Type::Sint64 => JsonType::Number,
}
}

View File

@@ -69,7 +69,7 @@ impl GrpcConnection {
service: &str,
method: &str,
message: &str,
metadata: BTreeMap<String, String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<DynamicMessage>, StreamError> {
let method = &self.method(&service, &method)?;
let input_message = method.input();
@@ -96,7 +96,7 @@ impl GrpcConnection {
service: &str,
method: &str,
stream: ReceiverStream<DynamicMessage>,
metadata: BTreeMap<String, String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<Streaming<DynamicMessage>>, StreamError> {
let method = &self.method(&service, &method)?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
@@ -116,7 +116,7 @@ impl GrpcConnection {
service: &str,
method: &str,
stream: ReceiverStream<DynamicMessage>,
metadata: BTreeMap<String, String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<DynamicMessage>, StreamError> {
let method = &self.method(&service, &method)?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
@@ -137,7 +137,7 @@ impl GrpcConnection {
service: &str,
method: &str,
message: &str,
metadata: BTreeMap<String, String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<Streaming<DynamicMessage>>, StreamError> {
let method = &self.method(&service, &method)?;
let input_message = method.input();
@@ -180,10 +180,11 @@ impl GrpcHandle {
id: &str,
uri: &str,
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
) -> Result<(), String> {
let pool = if proto_files.is_empty() {
let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(&full_uri).await
fill_pool_from_reflection(&full_uri, metadata).await
} else {
fill_pool_from_files(&self.app_handle, proto_files).await
}?;
@@ -197,9 +198,10 @@ impl GrpcHandle {
id: &str,
uri: &str,
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
) -> Result<Vec<ServiceDefinition>, String> {
// Ensure reflection is up-to-date
self.reflect(id, uri, proto_files).await?;
self.reflect(id, uri, proto_files, metadata).await?;
let pool = self.get_pool(id, uri, proto_files).ok_or("Failed to get pool".to_string())?;
Ok(self.services_from_pool(&pool))
@@ -235,8 +237,9 @@ impl GrpcHandle {
id: &str,
uri: &str,
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
) -> Result<GrpcConnection, String> {
self.reflect(id, uri, proto_files).await?;
self.reflect(id, uri, proto_files, metadata).await?;
let pool = self.get_pool(id, uri, proto_files).ok_or("Failed to get pool")?;
let uri = uri_from_str(uri)?;
@@ -254,7 +257,10 @@ impl GrpcHandle {
}
}
fn decorate_req<T>(metadata: BTreeMap<String, String>, req: &mut Request<T>) -> Result<(), String> {
pub(crate) fn decorate_req<T>(
metadata: &BTreeMap<String, String>,
req: &mut Request<T>,
) -> Result<(), String> {
for (k, v) in metadata {
req.metadata_mut().insert(
MetadataKey::from_str(k.as_str()).map_err(|e| e.to_string())?,

View File

@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::env::temp_dir;
use std::ops::Deref;
use std::path::PathBuf;
@@ -89,11 +90,14 @@ pub async fn fill_pool_from_files(
Ok(pool)
}
pub async fn fill_pool_from_reflection(uri: &Uri) -> Result<DescriptorPool, String> {
pub async fn fill_pool_from_reflection(
uri: &Uri,
metadata: &BTreeMap<String, String>,
) -> Result<DescriptorPool, String> {
let mut pool = DescriptorPool::new();
let mut client = AutoReflectionClient::new(uri);
for service in list_services(&mut client).await? {
for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" {
continue;
}
@@ -101,14 +105,18 @@ pub async fn fill_pool_from_reflection(uri: &Uri) -> Result<DescriptorPool, Stri
// TODO: update reflection client to use v1
continue;
}
file_descriptor_set_from_service_name(&service, &mut pool, &mut client).await;
file_descriptor_set_from_service_name(&service, &mut pool, &mut client, metadata).await;
}
Ok(pool)
}
async fn list_services(client: &mut AutoReflectionClient) -> Result<Vec<String>, String> {
let response = client.send_reflection_request(MessageRequest::ListServices("".into())).await?;
async fn list_services(
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) -> Result<Vec<String>, String> {
let response =
client.send_reflection_request(MessageRequest::ListServices("".into()), metadata).await?;
let list_services_response = match response {
MessageResponse::ListServicesResponse(resp) => resp,
@@ -122,9 +130,13 @@ async fn file_descriptor_set_from_service_name(
service_name: &str,
pool: &mut DescriptorPool,
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) {
let response = match client
.send_reflection_request(MessageRequest::FileContainingSymbol(service_name.into()))
.send_reflection_request(
MessageRequest::FileContainingSymbol(service_name.into()),
metadata,
)
.await
{
Ok(resp) => resp,
@@ -139,8 +151,13 @@ async fn file_descriptor_set_from_service_name(
_ => panic!("Expected a FileDescriptorResponse variant"),
};
add_file_descriptors_to_pool(file_descriptor_response.file_descriptor_proto, pool, client)
.await;
add_file_descriptors_to_pool(
file_descriptor_response.file_descriptor_proto,
pool,
client,
metadata,
)
.await;
}
#[async_recursion]
@@ -148,6 +165,7 @@ async fn add_file_descriptors_to_pool(
fds: Vec<Vec<u8>>,
pool: &mut DescriptorPool,
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) {
let mut topo_sort = topology::SimpleTopoSort::new();
let mut fd_mapping = std::collections::HashMap::with_capacity(fds.len());
@@ -165,7 +183,7 @@ async fn add_file_descriptors_to_pool(
if let Some(fdp) = fd_mapping.remove(&node) {
pool.add_file_descriptor_proto(fdp).expect("add file descriptor proto");
} else {
file_descriptor_set_by_filename(node.as_str(), pool, client).await;
file_descriptor_set_by_filename(node.as_str(), pool, client, metadata).await;
}
}
Err(_) => panic!("proto file got cycle!"),
@@ -177,6 +195,7 @@ async fn file_descriptor_set_by_filename(
filename: &str,
pool: &mut DescriptorPool,
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) {
// We already fetched this file
if let Some(_) = pool.get_file_by_name(filename) {
@@ -184,7 +203,7 @@ async fn file_descriptor_set_by_filename(
}
let msg = MessageRequest::FileByFilename(filename.into());
let response = client.send_reflection_request(msg).await;
let response = client.send_reflection_request(msg, metadata).await;
let file_descriptor_response = match response {
Ok(MessageResponse::FileDescriptorResponse(resp)) => resp,
Ok(_) => {
@@ -196,8 +215,13 @@ async fn file_descriptor_set_by_filename(
}
};
add_file_descriptors_to_pool(file_descriptor_response.file_descriptor_proto, pool, client)
.await;
add_file_descriptors_to_pool(
file_descriptor_response.file_descriptor_proto,
pool,
client,
metadata,
)
.await;
}
pub fn method_desc_to_path(md: &MethodDescriptor) -> PathAndQuery {

View File

@@ -1,60 +1,22 @@
use crate::error::Result;
use crate::{
activate_license, check_license, deactivate_license, ActivateLicenseRequestPayload,
CheckActivationRequestPayload, DeactivateLicenseRequestPayload, LicenseCheckStatus,
};
use crate::{LicenseCheckStatus, activate_license, check_license, deactivate_license};
use log::{debug, info};
use std::string::ToString;
use tauri::{command, Manager, Runtime, WebviewWindow};
use tauri::{Runtime, WebviewWindow, command};
#[command]
pub async fn check<R: Runtime>(window: WebviewWindow<R>) -> Result<LicenseCheckStatus> {
debug!("Checking license");
check_license(
&window,
CheckActivationRequestPayload {
app_platform: get_os().to_string(),
app_version: window.package_info().version.to_string(),
},
)
.await
check_license(&window).await
}
#[command]
pub async fn activate<R: Runtime>(license_key: &str, window: WebviewWindow<R>) -> Result<()> {
info!("Activating license {}", license_key);
activate_license(
&window,
ActivateLicenseRequestPayload {
license_key: license_key.to_string(),
app_platform: get_os().to_string(),
app_version: window.app_handle().package_info().version.to_string(),
},
)
.await
activate_license(&window, license_key).await
}
#[command]
pub async fn deactivate<R: Runtime>(window: WebviewWindow<R>) -> Result<()> {
info!("Deactivating activation");
deactivate_license(
&window,
DeactivateLicenseRequestPayload {
app_platform: get_os().to_string(),
app_version: window.app_handle().package_info().version.to_string(),
},
)
.await
}
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"
}
deactivate_license(&window).await
}

View File

@@ -16,3 +16,15 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.invoke_handler(generate_handler![check, activate, deactivate])
.build()
}
pub(crate) 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"
}
}

View File

@@ -5,7 +5,7 @@ use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::ops::Add;
use std::time::Duration;
use tauri::{is_dev, AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use ts_rs::TS;
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
@@ -63,10 +63,15 @@ pub struct APIErrorResponsePayload {
pub async fn activate_license<R: Runtime>(
window: &WebviewWindow<R>,
p: ActivateLicenseRequestPayload,
license_key: &str,
) -> Result<()> {
let client = reqwest::Client::new();
let response = client.post(build_url("/licenses/activate")).json(&p).send().await?;
let payload = ActivateLicenseRequestPayload {
license_key: license_key.to_string(),
app_platform: crate::get_os().to_string(),
app_version: window.app_handle().package_info().version.to_string(),
};
let response = client.post(build_url("/licenses/activate")).json(&payload).send().await?;
if response.status().is_client_error() {
let body: APIErrorResponsePayload = response.json().await?;
@@ -95,16 +100,17 @@ pub async fn activate_license<R: Runtime>(
Ok(())
}
pub async fn deactivate_license<R: Runtime>(
window: &WebviewWindow<R>,
p: DeactivateLicenseRequestPayload,
) -> Result<()> {
pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<()> {
let app_handle = window.app_handle();
let activation_id = get_activation_id(app_handle).await;
let client = reqwest::Client::new();
let path = format!("/licenses/activations/{}/deactivate", activation_id);
let response = client.post(build_url(&path)).json(&p).send().await?;
let payload = DeactivateLicenseRequestPayload {
app_platform: crate::get_os().to_string(),
app_version: window.app_handle().package_info().version.to_string(),
};
let response = client.post(build_url(&path)).json(&payload).send().await?;
if response.status().is_client_error() {
let body: APIErrorResponsePayload = response.json().await?;
@@ -141,10 +147,11 @@ pub enum LicenseCheckStatus {
Trialing { end: NaiveDateTime },
}
pub async fn check_license<R: Runtime>(
window: &WebviewWindow<R>,
payload: CheckActivationRequestPayload,
) -> Result<LicenseCheckStatus> {
pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<LicenseCheckStatus> {
let payload = CheckActivationRequestPayload {
app_platform: crate::get_os().to_string(),
app_version: window.package_info().version.to_string(),
};
let activation_id = get_activation_id(window.app_handle()).await;
let settings = window.db().get_settings();
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS));
@@ -197,9 +204,5 @@ fn build_url(path: &str) -> String {
}
pub async fn get_activation_id<R: Runtime>(app_handle: &AppHandle<R>) -> String {
app_handle.db().get_key_value_string(
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
"",
)
app_handle.db().get_key_value_string(KV_ACTIVATION_ID_KEY, KV_NAMESPACE, "")
}

View File

@@ -14,11 +14,11 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
@@ -28,9 +28,7 @@ export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, up
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
@@ -50,15 +48,19 @@ export type ModelChangeEvent = { "type": "upsert" } | { "type": "delete" };
export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, };
export type ParentAuthentication = { authentication: Record<string, any>, authenticationType: string | null, };
export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, };
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, } | { "type": "disabled" };
export type ProxySetting = { "type": "enabled", disabled: boolean, http: string, https: string, auth: ProxySettingAuth | null, } | { "type": "disabled" };
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, 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, hideWindowControls: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
@@ -76,6 +78,6 @@ export type WebsocketMessageType = "text" | "binary";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

View File

@@ -53,9 +53,10 @@ let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | null) {
console.log('Syncing models with new workspace', workspaceId);
const workspaceModels = await invoke<AnyModel[]>('plugin:yaak-models|workspace_models', {
const workspaceModelsStr = await invoke<string>('plugin:yaak-models|workspace_models', {
workspaceId, // NOTE: if no workspace id provided, it will just fetch global models
});
const workspaceModels = JSON.parse(workspaceModelsStr) as AnyModel[];
const data = newStoreData();
for (const model of workspaceModels) {
data[model.model][model.id] = model;

View File

@@ -94,7 +94,7 @@ pub(crate) fn get_settings<R: Runtime>(app_handle: AppHandle<R>) -> Result<Setti
pub(crate) fn workspace_models<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: Option<&str>,
) -> Result<Vec<AnyModel>> {
) -> Result<String> {
let db = window.db();
let mut l: Vec<AnyModel> = Vec::new();
@@ -109,7 +109,7 @@ pub(crate) fn workspace_models<R: Runtime>(
// Add the workspace children
if let Some(wid) = workspace_id {
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
@@ -120,5 +120,36 @@ pub(crate) fn workspace_models<R: Runtime>(
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
}
Ok(l)
let j = serde_json::to_string(&l)?;
// NOTE: There's something weird that happens on Linux. If we send Cyrillic (or maybe other)
// unicode characters in this response (doesn't matter where) then the following bug happens:
// https://feedback.yaak.app/p/editing-the-url-sometimes-freezes-the-app
//
// It's as if every string resulting from the JSON.parse of the models gets encoded slightly
// wrong or something, causing the above bug where Codemirror can't calculate the cursor
// position anymore (even when none of the characters are included directly in the input).
//
// For some reason using escape sequences works, but it's a hacky fix. Hopefully the Linux
// webview dependency updates to a version where this bug doesn't exist, or we can use CEF
// (Chromium) for Linux in the future, which Tauri is working on.
Ok(escape_str_for_webview(&j))
}
fn escape_str_for_webview(input: &str) -> String {
input.chars().map(|c| {
let code = c as u32;
// ASCII
if code <= 0x7F {
c.to_string()
// BMP characters encoded normally
} else if code < 0xFFFF {
format!("\\u{:04X}", code)
// Beyond BMP encoded a surrogate pairs
} else {
let high = ((code - 0x10000) >> 10) + 0xD800;
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
format!("\\u{:04X}\\u{:04X}", high, low)
}
}).collect()
}

View File

@@ -21,6 +21,12 @@ pub enum Error {
#[error("Model error: {0}")]
GenericError(String),
#[error("No base environment for {0}")]
MissingBaseEnvironment(String),
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
MultipleBaseEnvironments(String),
#[error("Row not found")]
RowNotFound,

View File

@@ -31,6 +31,9 @@ macro_rules! impl_model {
#[ts(export, export_to = "gen_models.ts")]
pub enum ProxySetting {
Enabled {
#[serde(default)]
// This was added after on so give it a default to be able to deserialize older values
disabled: bool,
http: String,
https: String,
auth: Option<ProxySettingAuth>,
@@ -102,6 +105,7 @@ pub struct Settings {
pub appearance: String,
pub editor_font_size: i32,
pub editor_soft_wrap: bool,
pub hide_window_controls: bool,
pub interface_font_size: i32,
pub interface_scale: f32,
pub open_workspace_new_window: Option<bool>,
@@ -151,6 +155,7 @@ impl UpsertModelInfo for Settings {
(EditorSoftWrap, self.editor_soft_wrap.into()),
(InterfaceFontSize, self.interface_font_size.into()),
(InterfaceScale, self.interface_scale.into()),
(HideWindowControls, self.hide_window_controls.into()),
(OpenWorkspaceNewWindow, self.open_workspace_new_window.into()),
(ThemeDark, self.theme_dark.as_str().into()),
(ThemeLight, self.theme_light.as_str().into()),
@@ -168,6 +173,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::EditorSoftWrap,
SettingsIden::InterfaceFontSize,
SettingsIden::InterfaceScale,
SettingsIden::HideWindowControls,
SettingsIden::OpenWorkspaceNewWindow,
SettingsIden::Proxy,
SettingsIden::ThemeDark,
@@ -197,6 +203,7 @@ impl UpsertModelInfo for Settings {
proxy: proxy.map(|p| -> ProxySetting { serde_json::from_str(p.as_str()).unwrap() }),
theme_dark: row.get("theme_dark")?,
theme_light: row.get("theme_light")?,
hide_window_controls: row.get("hide_window_controls")?,
update_channel: row.get("update_channel")?,
})
}
@@ -212,8 +219,13 @@ pub struct Workspace {
pub id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub name: String,
#[ts(type = "Record<string, any>")]
pub authentication: BTreeMap<String, Value>,
pub authentication_type: Option<String>,
pub description: String,
pub headers: Vec<HttpRequestHeader>,
pub name: String,
pub encryption_key_challenge: Option<String>,
// Settings
@@ -254,6 +266,9 @@ impl UpsertModelInfo for Workspace {
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
(Name, self.name.trim().into()),
(Authentication, serde_json::to_string(&self.authentication)?.into()),
(AuthenticationType, self.authentication_type.into()),
(Headers, serde_json::to_string(&self.headers)?.into()),
(Description, self.description.into()),
(EncryptionKeyChallenge, self.encryption_key_challenge.into()),
(SettingFollowRedirects, self.setting_follow_redirects.into()),
@@ -266,6 +281,9 @@ impl UpsertModelInfo for Workspace {
vec![
WorkspaceIden::UpdatedAt,
WorkspaceIden::Name,
WorkspaceIden::Authentication,
WorkspaceIden::AuthenticationType,
WorkspaceIden::Headers,
WorkspaceIden::Description,
WorkspaceIden::EncryptionKeyChallenge,
WorkspaceIden::SettingRequestTimeout,
@@ -279,6 +297,8 @@ impl UpsertModelInfo for Workspace {
where
Self: Sized,
{
let headers: String = row.get("headers")?;
let authentication: String = row.get("authentication")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -287,6 +307,9 @@ impl UpsertModelInfo for Workspace {
name: row.get("name")?,
description: row.get("description")?,
encryption_key_challenge: row.get("encryption_key_challenge")?,
headers: serde_json::from_str(&headers).unwrap_or_default(),
authentication: serde_json::from_str(&authentication).unwrap_or_default(),
authentication_type: row.get("authentication_type")?,
setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?,
setting_validate_certificates: row.get("setting_validate_certificates")?,
@@ -486,11 +509,12 @@ pub struct Environment {
pub model: String,
pub id: String,
pub workspace_id: String,
pub environment_id: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub name: String,
pub public: bool,
pub base: bool,
pub variables: Vec<EnvironmentVariable>,
}
@@ -523,9 +547,10 @@ impl UpsertModelInfo for Environment {
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
(EnvironmentId, self.environment_id.into()),
(WorkspaceId, self.workspace_id.into()),
(Base, self.base.into()),
(Name, self.name.trim().into()),
(Public, self.public.into()),
(Variables, serde_json::to_string(&self.variables)?.into()),
])
}
@@ -533,7 +558,9 @@ impl UpsertModelInfo for Environment {
fn update_columns() -> Vec<impl IntoIden> {
vec![
EnvironmentIden::UpdatedAt,
EnvironmentIden::Base,
EnvironmentIden::Name,
EnvironmentIden::Public,
EnvironmentIden::Variables,
]
}
@@ -547,10 +574,11 @@ impl UpsertModelInfo for Environment {
id: row.get("id")?,
model: row.get("model")?,
workspace_id: row.get("workspace_id")?,
environment_id: row.get("environment_id")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
base: row.get("base")?,
name: row.get("name")?,
public: row.get("public")?,
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),
})
}
@@ -569,6 +597,22 @@ pub struct EnvironmentVariable {
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ParentAuthentication {
#[ts(type = "Record<string, any>")]
pub authentication: BTreeMap<String, Value>,
pub authentication_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ParentHeaders {
pub headers: Vec<HttpRequestHeader>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
@@ -582,8 +626,12 @@ pub struct Folder {
pub workspace_id: String,
pub folder_id: Option<String>,
pub name: String,
#[ts(type = "Record<string, any>")]
pub authentication: BTreeMap<String, Value>,
pub authentication_type: Option<String>,
pub description: String,
pub headers: Vec<HttpRequestHeader>,
pub name: String,
pub sort_priority: f32,
}
@@ -618,8 +666,11 @@ impl UpsertModelInfo for Folder {
(UpdatedAt, upsert_date(source, self.updated_at)),
(WorkspaceId, self.workspace_id.into()),
(FolderId, self.folder_id.into()),
(Name, self.name.trim().into()),
(Authentication, serde_json::to_string(&self.authentication)?.into()),
(AuthenticationType, self.authentication_type.into()),
(Headers, serde_json::to_string(&self.headers)?.into()),
(Description, self.description.into()),
(Name, self.name.trim().into()),
(SortPriority, self.sort_priority.into()),
])
}
@@ -628,6 +679,9 @@ impl UpsertModelInfo for Folder {
vec![
FolderIden::UpdatedAt,
FolderIden::Name,
FolderIden::Authentication,
FolderIden::AuthenticationType,
FolderIden::Headers,
FolderIden::Description,
FolderIden::FolderId,
FolderIden::SortPriority,
@@ -638,6 +692,8 @@ impl UpsertModelInfo for Folder {
where
Self: Sized,
{
let headers: String = row.get("headers")?;
let authentication: String = row.get("authentication")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -648,6 +704,9 @@ impl UpsertModelInfo for Folder {
folder_id: row.get("folder_id")?,
name: row.get("name")?,
description: row.get("description")?,
headers: serde_json::from_str(&headers).unwrap_or_default(),
authentication_type: row.get("authentication_type")?,
authentication: serde_json::from_str(&authentication).unwrap_or_default(),
})
}
}
@@ -770,28 +829,28 @@ impl UpsertModelInfo for HttpRequest {
]
}
fn from_row(r: &Row) -> rusqlite::Result<Self> {
let url_parameters: String = r.get("url_parameters")?;
let body: String = r.get("body")?;
let authentication: String = r.get("authentication")?;
let headers: String = r.get("headers")?;
fn from_row(row: &Row) -> rusqlite::Result<Self> {
let url_parameters: String = row.get("url_parameters")?;
let body: String = row.get("body")?;
let authentication: String = row.get("authentication")?;
let headers: String = row.get("headers")?;
Ok(Self {
id: r.get("id")?,
model: r.get("model")?,
workspace_id: r.get("workspace_id")?,
created_at: r.get("created_at")?,
updated_at: r.get("updated_at")?,
id: row.get("id")?,
model: row.get("model")?,
workspace_id: row.get("workspace_id")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(),
authentication_type: r.get("authentication_type")?,
authentication_type: row.get("authentication_type")?,
body: serde_json::from_str(body.as_str()).unwrap_or_default(),
body_type: r.get("body_type")?,
description: r.get("description")?,
folder_id: r.get("folder_id")?,
body_type: row.get("body_type")?,
description: row.get("description")?,
folder_id: row.get("folder_id")?,
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
method: r.get("method")?,
name: r.get("name")?,
sort_priority: r.get("sort_priority")?,
url: r.get("url")?,
method: row.get("method")?,
name: row.get("name")?,
sort_priority: row.get("sort_priority")?,
url: row.get("url")?,
url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),
})
}
@@ -980,7 +1039,7 @@ impl UpsertModelInfo for WebsocketRequest {
(WorkspaceId, self.workspace_id.into()),
(FolderId, self.folder_id.as_ref().map(|s| s.as_str()).into()),
(Authentication, serde_json::to_string(&self.authentication)?.into()),
(AuthenticationType, self.authentication_type.as_ref().map(|s| s.as_str()).into()),
(AuthenticationType, self.authentication_type.into()),
(Description, self.description.into()),
(Headers, serde_json::to_string(&self.headers)?.into()),
(Message, self.message.into()),
@@ -1283,19 +1342,6 @@ impl UpsertModelInfo for HttpResponse {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct GrpcMetadataEntry {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
pub name: String,
pub value: String,
#[ts(optional, as = "Option<String>")]
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
@@ -1314,7 +1360,7 @@ pub struct GrpcRequest {
pub authentication: BTreeMap<String, Value>,
pub description: String,
pub message: String,
pub metadata: Vec<GrpcMetadataEntry>,
pub metadata: Vec<HttpRequestHeader>,
pub method: Option<String>,
pub name: String,
pub service: Option<String>,
@@ -1989,6 +2035,7 @@ impl<'de> Deserialize<'de> for AnyModel {
Some(m) if m == "grpc_event" => AnyModel::GrpcEvent(fv(value).unwrap()),
Some(m) if m == "grpc_request" => AnyModel::GrpcRequest(fv(value).unwrap()),
Some(m) if m == "http_request" => AnyModel::HttpRequest(fv(value).unwrap()),
Some(m) if m == "http_response" => AnyModel::HttpResponse(fv(value).unwrap()),
Some(m) if m == "key_value" => AnyModel::KeyValue(fv(value).unwrap()),
Some(m) if m == "plugin" => AnyModel::Plugin(fv(value).unwrap()),
Some(m) if m == "settings" => AnyModel::Settings(fv(value).unwrap()),

View File

@@ -1,8 +1,9 @@
use crate::db_context::DbContext;
use crate::error::Error::GenericError;
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
use crate::error::Result;
use crate::models::{Environment, EnvironmentIden};
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
use crate::util::UpdateSource;
use log::info;
impl<'a> DbContext<'a> {
pub fn get_environment(&self, id: &str) -> Result<Environment> {
@@ -10,35 +11,41 @@ impl<'a> DbContext<'a> {
}
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
// Will create base environment if it doesn't exist
let environments = self.list_environments(workspace_id)?;
let environments = self.list_environments_ensure_base(workspace_id)?;
let base_environments =
environments.into_iter().filter(|e| e.base).collect::<Vec<Environment>>();
let base_environment = environments
.into_iter()
.find(|e| e.environment_id == None && e.workspace_id == workspace_id)
.ok_or(GenericError(format!("No base environment found for {workspace_id}")))?;
if base_environments.len() > 1 {
return Err(MultipleBaseEnvironments(workspace_id.to_string()));
}
let base_environment = base_environments.into_iter().find(|e| e.base).ok_or(
// Should never happen because one should be created above if it does not exist
MissingBaseEnvironment(workspace_id.to_string()),
)?;
Ok(base_environment)
}
pub fn list_environments(&self, workspace_id: &str) -> Result<Vec<Environment>> {
/// Lists environments and will create a base environment if one doesn't exist
pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {
let mut environments =
self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?;
let base_environment = environments
.iter()
.find(|e| e.environment_id == None && e.workspace_id == workspace_id);
let base_environment = environments.iter().find(|e| e.base);
if let None = base_environment {
environments.push(self.upsert_environment(
let e = self.upsert_environment(
&Environment {
workspace_id: workspace_id.to_string(),
environment_id: None,
base: true,
name: "Global Variables".to_string(),
..Default::default()
},
&UpdateSource::Background,
)?);
)?;
info!("Created base environment {} for {workspace_id}", e.id);
environments.push(e);
}
Ok(environments)
@@ -49,12 +56,12 @@ impl<'a> DbContext<'a> {
environment: &Environment,
source: &UpdateSource,
) -> Result<Environment> {
for environment in
self.find_many::<Environment>(EnvironmentIden::EnvironmentId, &environment.id, None)?
{
self.delete_environment(&environment, source)?;
}
self.delete(environment, source)
let deleted_environment = self.delete(environment, source)?;
// Recreate the base environment if we happened to delete it
self.list_environments_ensure_base(&environment.workspace_id)?;
Ok(deleted_environment)
}
pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result<Environment> {
@@ -69,7 +76,7 @@ impl<'a> DbContext<'a> {
) -> Result<Environment> {
let mut environment = environment.clone();
environment.id = "".to_string();
self.upsert(&environment, source)
self.upsert_environment(&environment, source)
}
pub fn upsert_environment(
@@ -77,6 +84,18 @@ impl<'a> DbContext<'a> {
environment: &Environment,
source: &UpdateSource,
) -> Result<Environment> {
self.upsert(environment, source)
let cleaned_variables = environment
.variables
.iter()
.filter(|v| !v.name.is_empty() || !v.value.is_empty())
.cloned()
.collect::<Vec<EnvironmentVariable>>();
self.upsert(
&Environment {
variables: cleaned_variables,
..environment.clone()
},
source,
)
}
}

View File

@@ -2,10 +2,12 @@ use crate::connection_or_tx::ConnectionOrTx;
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{
Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestIden,
WebsocketRequest, WebsocketRequestIden,
Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader,
HttpRequestIden, WebsocketRequest, WebsocketRequestIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_folder(&self, id: &str) -> Result<Folder> {
@@ -110,4 +112,40 @@ impl<'a> DbContext<'a> {
Ok(new_folder)
}
pub fn resolve_auth_for_folder(
&self,
folder: Folder,
) -> Result<(Option<String>, BTreeMap<String, Value>)> {
if let Some(at) = folder.authentication_type {
return Ok((Some(at), folder.authentication));
}
if let Some(folder_id) = folder.folder_id {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(folder);
}
let workspace = self.get_workspace(&folder.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_folder(&self, folder: &Folder) -> Result<Vec<HttpRequestHeader>> {
let mut headers = Vec::new();
if let Some(folder_id) = folder.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
// NOTE: Add parent headers first, so overrides are logical
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&folder.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
headers.append(&mut folder.headers.clone());
Ok(headers)
}
}

View File

@@ -1,7 +1,9 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{GrpcRequest, GrpcRequestIden};
use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_grpc_request(&self, id: &str) -> Result<GrpcRequest> {
@@ -48,4 +50,43 @@ impl<'a> DbContext<'a> {
) -> Result<GrpcRequest> {
self.upsert(grpc_request, source)
}
pub fn resolve_auth_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>)> {
if let Some(at) = grpc_request.authentication_type.clone() {
return Ok((Some(at), grpc_request.authentication.clone()));
}
if let Some(folder_id) = grpc_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(folder);
}
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_metadata_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
) -> Result<Vec<HttpRequestHeader>> {
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut metadata = Vec::new();
if let Some(folder_id) = grpc_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
metadata.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
let mut workspace_metadata = self.resolve_headers_for_workspace(&workspace);
metadata.append(&mut workspace_metadata);
}
metadata.append(&mut grpc_request.metadata.clone());
Ok(metadata)
}
}

View File

@@ -1,7 +1,9 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{HttpRequest, HttpRequestIden};
use crate::models::{HttpRequest, HttpRequestHeader, HttpRequestIden};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_http_request(&self, id: &str) -> Result<HttpRequest> {
@@ -48,4 +50,43 @@ impl<'a> DbContext<'a> {
) -> Result<HttpRequest> {
self.upsert(http_request, source)
}
pub fn resolve_auth_for_http_request(
&self,
http_request: &HttpRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>)> {
if let Some(at) = http_request.authentication_type.clone() {
return Ok((Some(at), http_request.authentication.clone()));
}
if let Some(folder_id) = http_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(folder);
}
let workspace = self.get_workspace(&http_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_http_request(
&self,
http_request: &HttpRequest,
) -> Result<Vec<HttpRequestHeader>> {
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut headers = Vec::new();
if let Some(folder_id) = http_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&http_request.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
headers.append(&mut http_request.headers.clone());
Ok(headers)
}
}

View File

@@ -11,6 +11,11 @@ impl<'a> DbContext<'a> {
let (sql, params) = Query::select()
.from(KeyValueIden::Table)
.column(Asterisk)
// Temporary clause to prevent bug when reverting to the previous version, before the
// ID column was added. A previous version will not know about ID and will create
// key/value entries that don't have one. This clause ensures they are not queried
// TODO: Add migration to delete key/values with NULL IDs later on, then remove this
.cond_where(Expr::col(KeyValueIden::Id).is_not_null())
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), KeyValue::from_row)?;

View File

@@ -23,6 +23,7 @@ impl<'a> DbContext<'a> {
editor_soft_wrap: true,
interface_font_size: 15,
interface_scale: 1.0,
hide_window_controls: false,
open_workspace_new_window: None,
proxy: None,
theme_dark: "yaak-dark".to_string(),

View File

@@ -1,7 +1,9 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{WebsocketRequest, WebsocketRequestIden};
use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_websocket_request(&self, id: &str) -> Result<WebsocketRequest> {
@@ -48,4 +50,47 @@ impl<'a> DbContext<'a> {
) -> Result<WebsocketRequest> {
self.upsert(websocket_request, source)
}
pub fn resolve_auth_for_websocket_request(
&self,
websocket_request: &WebsocketRequest,
) -> Result<(Option<String>, BTreeMap<String, Value>)> {
if let Some(at) = websocket_request.authentication_type.clone() {
return Ok((Some(at), websocket_request.authentication.clone()));
}
if let Some(folder_id) = websocket_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
return self.resolve_auth_for_folder(folder);
}
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
Ok(self.resolve_auth_for_workspace(&workspace))
}
pub fn resolve_headers_for_websocket_request(
&self,
websocket_request: &WebsocketRequest,
) -> Result<Vec<HttpRequestHeader>> {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut headers = Vec::new();
headers.append(&mut workspace.headers.clone());
if let Some(folder_id) = websocket_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
headers.append(&mut folder_headers);
} else {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);
headers.append(&mut workspace_headers);
}
headers.append(&mut websocket_request.headers.clone());
Ok(headers)
}
}

View File

@@ -2,6 +2,7 @@ use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{WorkspaceMeta, WorkspaceMetaIden};
use crate::util::UpdateSource;
use log::info;
impl<'a> DbContext<'a> {
pub fn get_workspace_meta(&self, workspace_id: &str) -> Option<WorkspaceMeta> {
@@ -23,10 +24,7 @@ impl<'a> DbContext<'a> {
Ok(workspace_metas)
}
pub fn get_or_create_workspace_meta(
&self,
workspace_id: &str,
) -> Result<WorkspaceMeta> {
pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
let workspace_meta = self.get_workspace_meta(workspace_id);
if let Some(workspace_meta) = workspace_meta {
return Ok(workspace_meta);
@@ -37,6 +35,8 @@ impl<'a> DbContext<'a> {
..Default::default()
};
info!("Creating WorkspaceMeta for {workspace_id}");
self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background)
}

View File

@@ -1,10 +1,12 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{
Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestIden,
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden,
EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
WebsocketRequestIden, Workspace, WorkspaceIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
impl<'a> DbContext<'a> {
pub fn get_workspace(&self, id: &str) -> Result<Workspace> {
@@ -34,24 +36,26 @@ impl<'a> DbContext<'a> {
workspace: &Workspace,
source: &UpdateSource,
) -> Result<Workspace> {
for m in self.find_many::<HttpRequest>(HttpRequestIden::WorkspaceId, &workspace.id, None)? {
for m in self.find_many(HttpRequestIden::WorkspaceId, &workspace.id, None)? {
self.delete_http_request(&m, source)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::WorkspaceId, &workspace.id, None)? {
for m in self.find_many(GrpcRequestIden::WorkspaceId, &workspace.id, None)? {
self.delete_grpc_request(&m, source)?;
}
for m in
self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, &workspace.id, None)?
{
for m in self.find_many(WebsocketRequestIden::FolderId, &workspace.id, None)? {
self.delete_websocket_request(&m, source)?;
}
for folder in self.find_many::<Folder>(FolderIden::WorkspaceId, &workspace.id, None)? {
self.delete_folder(&folder, source)?;
for m in self.find_many(FolderIden::WorkspaceId, &workspace.id, None)? {
self.delete_folder(&m, source)?;
}
for m in self.find_many(EnvironmentIden::WorkspaceId, &workspace.id, None)? {
self.delete_environment(&m, source)?;
}
self.delete(workspace, source)
}
@@ -63,4 +67,15 @@ impl<'a> DbContext<'a> {
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
self.upsert(w, source)
}
pub fn resolve_auth_for_workspace(
&self,
workspace: &Workspace,
) -> (Option<String>, BTreeMap<String, Value>) {
(workspace.authentication_type.clone(), workspace.authentication.clone())
}
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
workspace.headers.clone()
}
}

View File

@@ -1,5 +1,8 @@
use crate::error::Result;
use crate::models::{AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest, Workspace, WorkspaceIden};
use crate::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest,
Workspace, WorkspaceIden,
};
use crate::query_manager::QueryManagerExt;
use chrono::{NaiveDateTime, Utc};
use log::warn;
@@ -117,14 +120,14 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>,
}
pub async fn get_workspace_export_resources<R: Runtime>(
pub fn get_workspace_export_resources<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_ids: Vec<&str>,
include_environments: bool,
include_private_environments: bool,
) -> Result<WorkspaceExport> {
let mut data = WorkspaceExport {
yaak_version: app_handle.package_info().version.clone().to_string(),
yaak_schema: 3,
yaak_schema: 4,
timestamp: Utc::now().naive_utc(),
resources: BatchUpsertResult {
workspaces: Vec::new(),
@@ -139,18 +142,19 @@ pub async fn get_workspace_export_resources<R: Runtime>(
let db = app_handle.db();
for workspace_id in workspace_ids {
data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?);
data.resources.environments.append(&mut db.list_environments(workspace_id)?);
data.resources.environments.append(
&mut db
.list_environments_ensure_base(workspace_id)?
.into_iter()
.filter(|e| include_private_environments || e.public)
.collect(),
);
data.resources.folders.append(&mut db.list_folders(workspace_id)?);
data.resources.http_requests.append(&mut db.list_http_requests(workspace_id)?);
data.resources.grpc_requests.append(&mut db.list_grpc_requests(workspace_id)?);
data.resources.websocket_requests.append(&mut db.list_websocket_requests(workspace_id)?);
}
// Nuke environments if we don't want them
if !include_environments {
data.resources.environments.clear();
}
Ok(data)
}

View File

@@ -1,14 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
@@ -24,4 +22,4 @@ export type HttpUrlParameter = { enabled?: boolean, name: string, value: string,
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -537,7 +537,7 @@ impl PluginManager {
auth_name: &str,
action_index: i32,
values: HashMap<String, JsonPrimitive>,
request_id: &str,
model_id: &str,
) -> Result<()> {
let results = self.get_http_authentication_summaries(window).await?;
let plugin = results
@@ -545,7 +545,7 @@ impl PluginManager {
.find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None })
.ok_or(PluginNotFoundErr(auth_name.into()))?;
let context_id = format!("{:x}", md5::compute(request_id.to_string()));
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
self.send_to_plugin_and_wait(
&PluginWindowContext::new(window),
&plugin,

View File

@@ -1,14 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
@@ -22,4 +20,4 @@ export type SyncState = { model: "sync_state", id: string, workspaceId: string,
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -4,4 +4,4 @@ import type { SyncState } from "./gen_models.js";
export type FsCandidate = { "type": "FsCandidate", model: SyncModel, relPath: string, checksum: string, };
export type SyncOp = { "type": "fsCreate", model: SyncModel, } | { "type": "fsUpdate", model: SyncModel, state: SyncState, } | { "type": "fsDelete", state: SyncState, fs: FsCandidate | null, } | { "type": "dbCreate", fs: FsCandidate, } | { "type": "dbUpdate", state: SyncState, fs: FsCandidate, } | { "type": "dbDelete", model: SyncModel, state: SyncState, };
export type SyncOp = { "type": "fsCreate", model: SyncModel, } | { "type": "fsUpdate", model: SyncModel, state: SyncState, } | { "type": "fsDelete", state: SyncState, fs: FsCandidate | null, } | { "type": "dbCreate", fs: FsCandidate, } | { "type": "dbUpdate", state: SyncState, fs: FsCandidate, } | { "type": "dbDelete", model: SyncModel, state: SyncState, } | { "type": "ignorePrivate", model: SyncModel, };

View File

@@ -1,7 +1,7 @@
use crate::error::Result;
use crate::sync::{
apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates, get_fs_candidates,
FsCandidate, SyncOp,
apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates, get_fs_candidates, FsCandidate,
SyncOp,
};
use crate::watch::{watch_directory, WatchEvent};
use chrono::Utc;
@@ -19,9 +19,8 @@ pub async fn calculate<R: Runtime>(
workspace_id: &str,
sync_dir: &Path,
) -> Result<Vec<SyncOp>> {
let db_candidates = get_db_candidates(&app_handle, workspace_id, sync_dir).await?;
let fs_candidates = get_fs_candidates(sync_dir)
.await?
let db_candidates = get_db_candidates(&app_handle, workspace_id, sync_dir)?;
let fs_candidates = get_fs_candidates(sync_dir)?
.into_iter()
// Only keep items in the same workspace
.filter(|fs| fs.model.workspace_id() == workspace_id)
@@ -34,7 +33,7 @@ pub async fn calculate<R: Runtime>(
#[command]
pub async fn calculate_fs(dir: &Path) -> Result<Vec<SyncOp>> {
let db_candidates = Vec::new();
let fs_candidates = get_fs_candidates(dir).await?;
let fs_candidates = get_fs_candidates(dir)?;
Ok(compute_sync_ops(db_candidates, fs_candidates))
}
@@ -45,8 +44,8 @@ pub async fn apply<R: Runtime>(
sync_dir: &Path,
workspace_id: &str,
) -> Result<()> {
let sync_state_ops = apply_sync_ops(&app_handle, &workspace_id, sync_dir, sync_ops).await?;
apply_sync_state_ops(&app_handle, workspace_id, sync_dir, sync_state_ops).await
let sync_state_ops = apply_sync_ops(&app_handle, &workspace_id, sync_dir, sync_ops)?;
apply_sync_state_ops(&app_handle, workspace_id, sync_dir, sync_state_ops)
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]

View File

@@ -130,4 +130,4 @@ impl TryFrom<AnyModel> for SyncModel {
};
Ok(m)
}
}
}

View File

@@ -1,15 +1,15 @@
use crate::error::Result;
use crate::models::SyncModel;
use chrono::Utc;
use log::{debug, info, warn};
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::fs;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Runtime};
use tokio::fs;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use ts_rs::TS;
use yaak_models::models::{SyncState, WorkspaceMeta};
use yaak_models::query_manager::QueryManagerExt;
@@ -41,17 +41,21 @@ pub(crate) enum SyncOp {
model: SyncModel,
state: SyncState,
},
IgnorePrivate {
model: SyncModel,
},
}
impl SyncOp {
fn workspace_id(&self) -> String {
match self {
SyncOp::FsCreate { model } => model.workspace_id(),
SyncOp::FsUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::FsDelete { state, .. } => state.workspace_id.clone(),
SyncOp::DbCreate { fs } => fs.model.workspace_id(),
SyncOp::DbUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::DbDelete { model, .. } => model.workspace_id(),
SyncOp::DbUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::FsCreate { model } => model.workspace_id(),
SyncOp::FsDelete { state, .. } => state.workspace_id.clone(),
SyncOp::FsUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::IgnorePrivate { model } => model.workspace_id(),
}
}
}
@@ -66,6 +70,7 @@ impl Display for SyncOp {
SyncOp::DbCreate { fs } => format!("db_create({})", fs.model.id()),
SyncOp::DbUpdate { fs, .. } => format!("db_update({})", fs.model.id()),
SyncOp::DbDelete { model, .. } => format!("db_delete({})", model.id()),
SyncOp::IgnorePrivate { model } => format!("ignore_private({})", model.id()),
}
.as_str(),
)
@@ -76,8 +81,8 @@ impl Display for SyncOp {
#[serde(rename_all = "snake_case")]
pub(crate) enum DbCandidate {
Added(SyncModel),
Modified(SyncModel, SyncState),
Deleted(SyncState),
Modified(SyncModel, SyncState),
Unmodified(SyncModel, SyncState),
}
@@ -85,8 +90,8 @@ impl DbCandidate {
fn model_id(&self) -> String {
match &self {
DbCandidate::Added(m) => m.id(),
DbCandidate::Modified(m, _) => m.id(),
DbCandidate::Deleted(s) => s.model_id.clone(),
DbCandidate::Modified(m, _) => m.id(),
DbCandidate::Unmodified(m, _) => m.id(),
}
}
@@ -101,16 +106,13 @@ pub(crate) struct FsCandidate {
pub(crate) checksum: String,
}
pub(crate) async fn get_db_candidates<R: Runtime>(
pub(crate) fn get_db_candidates<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_id: &str,
sync_dir: &Path,
) -> Result<Vec<DbCandidate>> {
let models: HashMap<_, _> = workspace_models(app_handle, workspace_id)
.await?
.into_iter()
.map(|m| (m.id(), m))
.collect();
let models: HashMap<_, _> =
workspace_models(app_handle, workspace_id)?.into_iter().map(|m| (m.id(), m)).collect();
let sync_states: HashMap<_, _> = app_handle
.db()
.list_sync_states_for_workspace(workspace_id, sync_dir)?
@@ -121,20 +123,42 @@ pub(crate) async fn get_db_candidates<R: Runtime>(
// 1. Add candidates for models (created/modified/unmodified)
let mut candidates: Vec<DbCandidate> = models
.values()
.map(|model| {
let existing_sync_state = match sync_states.get(&model.id()) {
Some(s) => s,
None => {
// No sync state yet, so model was just added
return DbCandidate::Added(model.to_owned());
}
};
.filter_map(|model| {
match sync_states.get(&model.id()) {
Some(existing_sync_state) => {
// If a sync state exists but the model is now private, treat it as a deletion
match model {
SyncModel::Environment(e) if !e.public => {
return Some(DbCandidate::Deleted(existing_sync_state.to_owned()));
}
_ => {}
};
let updated_since_flush = model.updated_at() > existing_sync_state.flushed_at;
if updated_since_flush {
DbCandidate::Modified(model.to_owned(), existing_sync_state.to_owned())
} else {
DbCandidate::Unmodified(model.to_owned(), existing_sync_state.to_owned())
let updated_since_flush = model.updated_at() > existing_sync_state.flushed_at;
if updated_since_flush {
Some(DbCandidate::Modified(
model.to_owned(),
existing_sync_state.to_owned(),
))
} else {
Some(DbCandidate::Unmodified(
model.to_owned(),
existing_sync_state.to_owned(),
))
}
}
None => {
return match model {
SyncModel::Environment(e) if !e.public => {
// No sync state yet, so ignore the model
None
}
_ => {
// No sync state yet, so the model was just added
Some(DbCandidate::Added(model.to_owned()))
}
};
}
}
})
.collect();
@@ -151,30 +175,28 @@ pub(crate) async fn get_db_candidates<R: Runtime>(
Ok(candidates)
}
pub(crate) async fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
pub(crate) fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
// Ensure the root directory exists
fs::create_dir_all(dir).await?;
fs::create_dir_all(dir)?;
let mut candidates = Vec::new();
let mut entries = fs::read_dir(dir).await?;
while let Some(dir_entry) = entries.next_entry().await? {
if !dir_entry.file_type().await?.is_file() {
let entries = fs::read_dir(dir)?;
for dir_entry in entries {
let dir_entry = match dir_entry {
Ok(v) => v,
Err(_) => continue,
};
if !dir_entry.file_type()?.is_file() {
continue;
};
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) => {
warn!("Failed to read sync file {e}");
warn!("Failed to parse sync file {e}");
return Err(e);
}
};
@@ -212,32 +234,32 @@ 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) => {
// 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(),
}
}
// DB unchanged <-> FS missing
(Some(DbCandidate::Unmodified(model, sync_state)), None) => SyncOp::DbDelete {
model: model.to_owned(),
state: sync_state.to_owned(),
},
// DB modified <-> FS missing
(Some(DbCandidate::Modified(model, sync_state)), None) => SyncOp::FsUpdate {
model: model.to_owned(),
state: sync_state.to_owned(),
},
// DB added <-> FS missing
(Some(DbCandidate::Added(model)), None) => SyncOp::FsCreate {
model: model.to_owned(),
},
(Some(DbCandidate::Deleted(sync_state)), None) => {
// Already deleted on FS, but sending it so the SyncState gets dealt with
SyncOp::FsDelete {
state: sync_state.to_owned(),
fs: None,
}
}
// DB deleted <-> FS missing
// Already deleted on FS, but sending it so the SyncState gets dealt with
(Some(DbCandidate::Deleted(sync_state)), None) => SyncOp::FsDelete {
state: sync_state.to_owned(),
fs: None,
},
// DB unchanged <-> FS exists
(Some(DbCandidate::Unmodified(_, sync_state)), Some(fs_candidate)) => {
if sync_state.checksum == fs_candidate.checksum {
return None;
@@ -248,6 +270,8 @@ pub(crate) fn compute_sync_ops(
}
}
}
// DB modified <-> FS exists
(Some(DbCandidate::Modified(model, sync_state)), Some(fs_candidate)) => {
if sync_state.checksum == fs_candidate.checksum {
SyncOp::FsUpdate {
@@ -255,25 +279,29 @@ pub(crate) fn compute_sync_ops(
state: sync_state.to_owned(),
}
} else if model.updated_at() < fs_candidate.model.updated_at() {
// CONFLICT! Write to DB if fs model is newer
// CONFLICT! Write to DB if the fs model is newer
SyncOp::DbUpdate {
state: sync_state.to_owned(),
fs: fs_candidate.to_owned(),
}
} else {
// CONFLICT! Write to FS if db model is newer
// CONFLICT! Write to FS if the db model is newer
SyncOp::FsUpdate {
model: model.to_owned(),
state: sync_state.to_owned(),
}
}
}
// DB added <-> FS anything
(Some(DbCandidate::Added(model)), Some(_)) => {
// This would be super rare (impossible?), so let's follow the user's intention
SyncOp::FsCreate {
model: model.to_owned(),
}
}
// DB deleted <-> FS exists
(Some(DbCandidate::Deleted(sync_state)), Some(fs_candidate)) => SyncOp::FsDelete {
state: sync_state.to_owned(),
fs: Some(fs_candidate.to_owned()),
@@ -284,12 +312,19 @@ pub(crate) fn compute_sync_ops(
.collect()
}
async fn workspace_models<R: Runtime>(
fn workspace_models<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_id: &str,
) -> Result<Vec<SyncModel>> {
let resources =
get_workspace_export_resources(app_handle, vec![workspace_id], true).await?.resources;
// We want to include private environments here so that we can take them into account during
// the sync process. Otherwise, they would be treated as deleted.
let include_private_environments = true;
let resources = get_workspace_export_resources(
app_handle,
vec![workspace_id],
include_private_environments,
)?
.resources;
let workspace = resources.workspaces.iter().find(|w| w.id == workspace_id);
let workspace = match workspace {
@@ -318,7 +353,7 @@ async fn workspace_models<R: Runtime>(
Ok(sync_models)
}
pub(crate) async fn apply_sync_ops<R: Runtime>(
pub(crate) fn apply_sync_ops<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_id: &str,
sync_dir: &Path,
@@ -328,13 +363,14 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
return Ok(Vec::new());
}
debug!(
info!(
"Applying sync ops {}",
sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(", ")
);
let mut sync_state_ops = Vec::new();
let mut workspaces_to_upsert = Vec::new();
let environments_to_upsert = Vec::new();
let mut 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();
@@ -351,8 +387,8 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
let rel_path = derive_model_filename(&model);
let abs_path = sync_dir.join(rel_path.clone());
let (content, checksum) = model.to_file_contents(&rel_path)?;
let mut f = File::create(&abs_path).await?;
f.write_all(&content).await?;
let mut f = File::create(&abs_path)?;
f.write_all(&content)?;
SyncStateOp::Create {
model_id: model.id(),
checksum,
@@ -364,8 +400,8 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
let rel_path = Path::new(&state.rel_path);
let abs_path = Path::new(&state.sync_dir).join(&rel_path);
let (content, checksum) = model.to_file_contents(&rel_path)?;
let mut f = File::create(&abs_path).await?;
f.write_all(&content).await?;
let mut f = File::create(&abs_path)?;
f.write_all(&content)?;
SyncStateOp::Update {
state: state.to_owned(),
checksum,
@@ -383,7 +419,7 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
// Always delete the existing path
let rel_path = Path::new(&state.rel_path);
let abs_path = Path::new(&state.sync_dir).join(&rel_path);
fs::remove_file(&abs_path).await?;
fs::remove_file(&abs_path)?;
SyncStateOp::Delete {
state: state.to_owned(),
}
@@ -395,14 +431,12 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
// Push updates to arrays so we can do them all in a single
// 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::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),
// TODO: Handle environments in sync
SyncModel::Environment(_) => {}
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
};
SyncStateOp::Create {
model_id,
@@ -414,14 +448,12 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
// Push updates to arrays so we can do them all in a single
// 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::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),
// TODO: Handle environments in sync
SyncModel::Environment(_) => {}
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
}
SyncStateOp::Update {
state: state.to_owned(),
@@ -435,6 +467,7 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
state: state.to_owned(),
}
}
SyncOp::IgnorePrivate { .. } => SyncStateOp::NoOp,
});
}
@@ -497,9 +530,10 @@ pub(crate) enum SyncStateOp {
Delete {
state: SyncState,
},
NoOp,
}
pub(crate) async fn apply_sync_state_ops<R: Runtime>(
pub(crate) fn apply_sync_state_ops<R: Runtime>(
app_handle: &AppHandle<R>,
workspace_id: &str,
sync_dir: &Path,
@@ -540,6 +574,9 @@ pub(crate) async fn apply_sync_state_ops<R: Runtime>(
SyncStateOp::Delete { state } => {
app_handle.db().delete_sync_state(&state)?;
}
SyncStateOp::NoOp => {
// Nothing
}
}
}
Ok(())

View File

@@ -4,6 +4,9 @@ version = "0.1.0"
edition = "2024"
publish = false
[package.metadata.wasm-pack.profile.release]
wasm-opt = false # Causes errors in CI (haven't figured out why yet)
[lib]
crate-type = ["cdylib", "rlib"]

View File

@@ -5,15 +5,15 @@ use log::{info, warn};
use std::str::FromStr;
use tauri::http::{HeaderMap, HeaderName};
use tauri::{AppHandle, Runtime, State, Url, WebviewWindow};
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use yaak_http::apply_path_placeholders;
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::models::{
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
WebsocketEventType, WebsocketRequest,
};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginWindowContext, RenderPurpose,
@@ -206,31 +206,58 @@ pub(crate) async fn connect<R: Runtime>(
)
.await?;
let (authentication_type, authentication) =
window.db().resolve_auth_for_websocket_request(&request)?;
let mut headers = HeaderMap::new();
if let Some(auth_name) = request.authentication_type.clone() {
let auth = request.authentication.clone();
let plugin_req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(request_id.to_string())),
values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(),
method: "POST".to_string(),
url: request.url.clone(),
headers: request
.headers
.clone()
.into_iter()
.map(|h| HttpHeader {
name: h.name,
value: h.value,
})
.collect(),
};
let plugin_result =
plugin_manager.call_http_authentication(&window, &auth_name, plugin_req).await?;
for header in plugin_result.set_headers {
headers.insert(
HeaderName::from_str(&header.name).unwrap(),
HeaderValue::from_str(&header.value).unwrap(),
);
let resolved_headers = window.db().resolve_headers_for_websocket_request(&request)?;
for h in resolved_headers {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
headers.insert(
HeaderName::from_str(&h.name).unwrap(),
HeaderValue::from_str(&h.value).unwrap(),
);
}
match authentication_type {
None => {
// No authentication found. Not even inherited
}
Some(authentication_type) if authentication_type == "none" => {
// Explicitly no authentication
}
Some(authentication_type) => {
let auth = authentication.clone();
let plugin_req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(request_id.to_string())),
values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(),
method: "POST".to_string(),
url: request.url.clone(),
headers: request
.headers
.clone()
.into_iter()
.map(|h| HttpHeader {
name: h.name,
value: h.value,
})
.collect(),
};
let plugin_result =
plugin_manager.call_http_authentication(&window, &authentication_type, plugin_req).await?;
for header in plugin_result.set_headers {
headers.insert(
HeaderName::from_str(&header.name).unwrap(),
HeaderValue::from_str(&header.value).unwrap(),
);
}
}
}
@@ -257,12 +284,16 @@ pub(crate) async fn connect<R: Runtime>(
// Add URL parameters to URL
let mut url = Url::parse(&url).unwrap();
{
let mut query_pairs = url.query_pairs_mut();
for p in url_parameters.clone() {
if !p.enabled || p.name.is_empty() {
continue;
let valid_query_pairs = url_parameters
.into_iter()
.filter(|p| p.enabled && !p.name.is_empty())
.collect::<Vec<_>>();
// NOTE: Only mutate query pairs if there are any, or it will append an empty `?` to the URL
if !valid_query_pairs.is_empty() {
let mut query_pairs = url.query_pairs_mut();
for p in valid_query_pairs {
query_pairs.append_pair(p.name.as_str(), p.value.as_str());
}
query_pairs.append_pair(p.name.as_str(), p.value.as_str());
}
}

View File

@@ -0,0 +1,51 @@
import { createWorkspaceModel, type Environment } from '@yaakapp-internal/models';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { jotaiStore } from '../lib/jotai';
import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
export const createEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ['create_environment'],
mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) {
throw new Error('No base environment passed');
}
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) {
throw new Error('Cannot create environment when no active workspace');
}
const name = await showPrompt({
id: 'new-environment',
title: 'New Environment',
description: 'Create multiple environments with different sets of variables',
label: 'Name',
placeholder: 'My Environment',
defaultValue: 'My Environment',
confirmText: 'Create',
});
if (name == null) return null;
return createWorkspaceModel({
model: 'environment',
name,
variables: [],
workspaceId,
base: false,
});
},
onSuccess: async (environmentId) => {
if (environmentId == null) {
return; // Was not created
}
console.log('NAVIGATING', jotaiStore.get(activeWorkspaceIdAtom), environmentId);
setWorkspaceSearchParams({ environment_id: environmentId });
},
});

View File

@@ -0,0 +1,14 @@
import type { FolderSettingsTab } from '../components/FolderSettingsDialog';
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
import { showDialog } from '../lib/dialog';
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
showDialog({
id: 'folder-settings',
title: 'Folder Settings',
size: 'lg',
className: 'h-[50rem]',
noPadding: true,
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
});
}

View File

@@ -1,4 +1,4 @@
import { SettingsTab } from '../components/Settings/SettingsTab';
import type { SettingsTab } from '../components/Settings/Settings';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { jotaiStore } from '../lib/jotai';
@@ -14,8 +14,9 @@ export const openSettings = createFastMutation<void, string, SettingsTab | null>
const location = router.buildLocation({
to: '/workspaces/$workspaceId/settings',
params: { workspaceId },
search: { tab: tab ?? SettingsTab.General },
search: { tab },
});
await invokeCmd('cmd_new_child_window', {
url: location.href,
label: 'settings',

View File

@@ -1,20 +1,22 @@
import { WorkspaceSettingsDialog } from '../components/WorkspaceSettingsDialog';
import type {
WorkspaceSettingsTab} from '../components/WorkspaceSettingsDialog';
import {
WorkspaceSettingsDialog
} from '../components/WorkspaceSettingsDialog';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
export const openWorkspaceSettings = createFastMutation<void, string>({
mutationKey: ['open_workspace_settings'],
async mutationFn() {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
showDialog({
id: 'workspace-settings',
title: 'Workspace Settings',
size: 'md',
render({ hide }) {
return <WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} />;
},
});
},
});
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
showDialog({
id: 'workspace-settings',
title: 'Workspace Settings',
size: 'lg',
className: 'h-[50rem]',
noPadding: true,
render({ hide }) {
return <WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />;
},
});
}

View File

@@ -5,6 +5,7 @@ import { useAtomValue } from 'jotai';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFolder } from '../commands/commands';
import { createEnvironmentAndActivate } from '../commands/createEnvironment';
import { openSettings } from '../commands/openSettings';
import { switchWorkspace } from '../commands/switchWorkspace';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
@@ -12,7 +13,6 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { useAllRequests } from '../hooks/useAllRequests';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
@@ -72,7 +72,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const activeCookieJar = useActiveCookieJar();
const [recentRequests] = useRecentRequests();
const [, setSidebarHidden] = useSidebarHidden();
const { mutate: createEnvironment } = useCreateEnvironment();
const { mutate: sendRequest } = useSendAnyHttpRequest();
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
@@ -139,7 +138,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{
key: 'environment.create',
label: 'Create Environment',
onSelect: () => createEnvironment(baseEnvironment),
onSelect: () => createEnvironmentAndActivate.mutate(baseEnvironment),
},
{
key: 'sidebar.toggle',
@@ -190,7 +189,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
activeEnvironment,
activeRequest,
baseEnvironment,
createEnvironment,
createWorkspace,
httpRequestActions,
sendRequest,

View File

@@ -1,5 +1,5 @@
import { useCopy } from '../hooks/useCopy';
import { useTimedBoolean } from '../hooks/useTimedBoolean';
import { copyToClipboard } from '../lib/copy';
import { showToast } from '../lib/toast';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
@@ -9,7 +9,6 @@ interface Props extends Omit<ButtonProps, 'onClick'> {
}
export function CopyButton({ text, ...props }: Props) {
const copy = useCopy({ disableToast: true });
const [copied, setCopied] = useTimedBoolean();
return (
<Button
@@ -23,8 +22,8 @@ export function CopyButton({ text, ...props }: Props) {
message: 'Failed to copy',
});
} else {
copy(content);
setCopied();
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
>

View File

@@ -1,5 +1,5 @@
import { useCopy } from '../hooks/useCopy';
import { useTimedBoolean } from '../hooks/useTimedBoolean';
import { copyToClipboard } from '../lib/copy';
import { showToast } from '../lib/toast';
import type { IconButtonProps } from './core/IconButton';
import { IconButton } from './core/IconButton';
@@ -9,7 +9,6 @@ interface Props extends Omit<IconButtonProps, 'onClick' | 'icon'> {
}
export function CopyIconButton({ text, ...props }: Props) {
const copy = useCopy({ disableToast: true });
const [copied, setCopied] = useTimedBoolean();
return (
<IconButton
@@ -25,7 +24,7 @@ export function CopyIconButton({ text, ...props }: Props) {
message: 'Failed to copy',
});
} else {
copy(content);
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}

View File

@@ -31,7 +31,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
as="form"
space={3}
alignItems="start"
className="pb-3 max-h-[50vh]"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: 'workspace', name });

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai';
import React from 'react';
import { dialogsAtom, hideDialog } from '../lib/dialog';
import { Dialog, type DialogProps } from './core/Dialog';
import { ErrorBoundary } from './ErrorBoundary';
export type DialogInstance = {
id: string;
@@ -22,16 +23,17 @@ export function Dialogs() {
function DialogInstance({ render, onClose, id, ...props }: DialogInstance) {
const children = render({ hide: () => hideDialog(id) });
return (
<Dialog
open
key={id}
onClose={() => {
onClose?.();
hideDialog(id);
}}
{...props}
>
{children}
</Dialog>
<ErrorBoundary name={`Dialog ${id}`}>
<Dialog
open
onClose={() => {
onClose?.();
hideDialog(id);
}}
{...props}
>
{children}
</Dialog>
</ErrorBoundary>
);
}

View File

@@ -65,6 +65,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
data={data}
className="pb-4" // Pad the bottom to look nice
/>
);
}
@@ -77,15 +78,17 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
setDataAttr,
data,
disabled,
className,
}: Pick<
Props<T>,
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
className?: string;
}) {
return (
<VStack space={3} className="h-full overflow-auto">
<VStack space={3} className={classNames(className, 'h-full overflow-auto')}>
{inputs?.map((input, i) => {
if ('hidden' in input && input.hidden) {
return null;

View File

@@ -1,14 +1,13 @@
import {VStack} from "./core/Stacks";
import { VStack } from './core/Stacks';
export function EncryptionHelp() {
return <VStack space={3}>
<p>
Encrypt values like secrets and tokens. When enabled, Yaak will also encrypt HTTP responses,
cookies, and authentication credentials automatically.
</p>
<p>
Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or
sharing with others.
</p>
return (
<VStack space={3}>
<p>Encrypt passwords, tokens, and other sensitive info when encryption is enabled.</p>
<p>
Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or
sharing with others.
</p>
</VStack>
);
}

View File

@@ -1,10 +1,10 @@
import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { createEnvironmentAndActivate } from '../commands/createEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';
@@ -12,6 +12,7 @@ import { useRandomKey } from '../hooks/useRandomKey';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption';
import { showPrompt } from '../lib/prompt';
import { resolvedModelName } from '../lib/resolvedModelName';
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
@@ -19,26 +20,29 @@ import {
import { BadgeButton } from './core/BadgeButton';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode';
import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { VStack } from './core/Stacks';
interface Props {
initialEnvironment: Environment | null;
}
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const createEnvironment = useCreateEnvironment();
const { baseEnvironment, subEnvironments, allEnvironments } = useEnvironmentsBreakdown();
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
);
@@ -50,10 +54,37 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return;
const id = await createEnvironment.mutateAsync(baseEnvironment);
setSelectedEnvironmentId(id);
const id = await createEnvironmentAndActivate.mutateAsync(baseEnvironment);
if (id != null) setSelectedEnvironmentId(id);
};
const handleDuplicateEnvironment = useCallback(async (environment: Environment) => {
const name = await showPrompt({
id: 'duplicate-environment',
title: 'Duplicate Environment',
label: 'Name',
defaultValue: environment.name,
});
if (name) {
const newId = await duplicateModel({ ...environment, name, public: false });
setSelectedEnvironmentId(newId);
}
}, []);
const handleDeleteEnvironment = useCallback(
async (environment: Environment) => {
await deleteModelWithConfirm(environment);
if (selectedEnvironmentId === environment.id) {
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
}
},
[baseEnvironment?.id, selectedEnvironmentId],
);
if (baseEnvironment == null) {
return null;
}
return (
<SplitLayout
name="env_editor"
@@ -63,24 +94,33 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
firstSlot={() => (
<aside className="w-full min-w-0 pt-2">
<div className="min-w-0 h-full overflow-y-auto pt-1">
<SidebarButton
active={selectedEnvironment?.id == baseEnvironment?.id}
onClick={() => setSelectedEnvironmentId(null)}
environment={null}
rightSlot={
<IconButton
size="sm"
iconSize="md"
title="Add sub environment"
icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle"
className="group"
onClick={handleCreateEnvironment}
/>
}
>
{baseEnvironment?.name}
</SidebarButton>
{[baseEnvironment, ...otherBaseEnvironments].map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id == e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
environment={e}
duplicateEnvironment={handleDuplicateEnvironment}
// Allow deleting base environment if there are multiples
deleteEnvironment={
otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null
}
rightSlot={e.public && sharableTooltip}
outerRightSlot={
<IconButton
size="sm"
iconSize="md"
title="Add sub environment"
icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle"
className="group mr-0.5"
onClick={handleCreateEnvironment}
/>
}
>
{resolvedModelName(e)}
</SidebarButton>
))}
{subEnvironments.length > 0 && (
<div className="px-2">
<Separator className="my-3"></Separator>
@@ -92,11 +132,9 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
active={selectedEnvironment?.id === e.id}
environment={e}
onClick={() => setSelectedEnvironmentId(e.id)}
onDelete={() => {
if (e.id === selectedEnvironmentId) {
setSelectedEnvironmentId(null);
}
}}
rightSlot={e.public && sharableTooltip}
duplicateEnvironment={handleDuplicateEnvironment}
deleteEnvironment={handleDeleteEnvironment}
>
{e.name}
</SidebarButton>
@@ -123,31 +161,30 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
};
const EnvironmentEditor = function ({
environment: activeEnvironment,
environment: selectedEnvironment,
className,
}: {
environment: Environment;
className?: string;
}) {
const activeWorkspaceId = activeEnvironment.workspaceId;
const workspaceId = selectedEnvironment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
namespace: 'global',
key: ['environmentValueVisibility', activeWorkspaceId],
key: ['environmentValueVisibility', workspaceId],
fallback: false,
});
const { allEnvironments } = useEnvironmentsBreakdown();
const handleChange = useCallback(
(variables: PairWithId[]) => patchModel(activeEnvironment, { variables }),
[activeEnvironment],
(variables: PairWithId[]) => patchModel(selectedEnvironment, { variables }),
[selectedEnvironment],
);
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments, to help the user get them aligned
// Gather a list of env names from other environments to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const options: GenericCompletionOption[] = [];
const isBaseEnv = activeEnvironment.environmentId == null;
if (isBaseEnv) {
if (selectedEnvironment.base) {
return { options };
}
@@ -157,7 +194,7 @@ const EnvironmentEditor = function ({
const containingEnvs = allEnvironments.filter((e) =>
e.variables.some((v) => v.name === name),
);
const isAlreadyInActive = containingEnvs.find((e) => e.id === activeEnvironment.id);
const isAlreadyInActive = containingEnvs.find((e) => e.id === selectedEnvironment.id);
if (isAlreadyInActive) continue;
options.push({
label: name,
@@ -166,10 +203,10 @@ const EnvironmentEditor = function ({
});
}
return { options };
}, [activeEnvironment.environmentId, activeEnvironment.id, allEnvironments]);
}, [selectedEnvironment.base, selectedEnvironment.id, allEnvironments]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet, and is unusable
// Empty just means the variable doesn't have a name yet and is unusable
if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_-]*$/i) != null;
}, []);
@@ -177,13 +214,13 @@ const EnvironmentEditor = function ({
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
const promptToEncrypt = useMemo(() => {
if (!isEncryptionEnabled) {
return false;
return true;
} else {
return !activeEnvironment.variables.every(
return !selectedEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
);
}
}, [activeEnvironment.variables, isEncryptionEnabled]);
}, [selectedEnvironment.variables, isEncryptionEnabled]);
const encryptEnvironment = (environment: Environment) => {
withEncryptionEnabled(async () => {
@@ -199,28 +236,37 @@ const EnvironmentEditor = function ({
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<HStack space={2} className="justify-between">
<Heading className="w-full flex items-center gap-1">
<div>{activeEnvironment?.name}</div>
{promptToEncrypt ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(activeEnvironment)}>
<Heading className="w-full flex items-center gap-0.5">
<div className="mr-2">{selectedEnvironment?.name}</div>
{isEncryptionEnabled ? (
promptToEncrypt ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(selectedEnvironment)}>
Encrypt All Variables
</BadgeButton>
) : isEncryptionEnabled ? (
) : (
<BadgeButton color="secondary" onClick={setupOrConfigureEncryption}>
Encryption Settings
</BadgeButton>
) : (
<IconButton
size="sm"
icon={valueVisibility.value ? 'eye' : 'eye_closed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}
onClick={() => valueVisibility.set((v) => !v)}
/>
)}
</Heading>
</HStack>
<div className="h-full pr-2 pb-2">
)
) : (
<>
<BadgeButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
{valueVisibility.value ? 'Conceal Values' : 'Reveal Values'}
</BadgeButton>
</>
)}
</Heading>
{selectedEnvironment.public && promptToEncrypt && (
<DismissibleBanner
id={`warn-unencrypted-${selectedEnvironment.id}`}
color="notice"
className="mr-3"
>
This environment is sharable. Ensure variable values are encrypted to avoid accidental
leaking of secrets during directory sync or data export.
</DismissibleBanner>
)}
<div className="h-full pr-2 pb-2 grid grid-rows-[minmax(0,1fr)] overflow-auto">
<PairOrBulkEditor
allowMultilineValues
preferenceName="environment"
@@ -230,10 +276,15 @@ const EnvironmentEditor = function ({
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteFunctions
forceUpdateKey={`${activeEnvironment.id}::${forceUpdateKey}`}
pairs={activeEnvironment.variables}
forceUpdateKey={`${selectedEnvironment.id}::${forceUpdateKey}`}
pairs={selectedEnvironment.variables}
onChange={handleChange}
stateKey={`environment.${activeEnvironment.id}`}
stateKey={`environment.${selectedEnvironment.id}`}
forcedEnvironmentId={
// Editing the base environment should resolve variables using the active environment.
// Editing a sub environment should resolve variables as if it's the active environment
selectedEnvironment.base ? undefined : selectedEnvironment.id
}
/>
</div>
</VStack>
@@ -245,17 +296,21 @@ function SidebarButton({
className,
active,
onClick,
onDelete,
deleteEnvironment,
rightSlot,
outerRightSlot,
duplicateEnvironment,
environment,
}: {
className?: string;
children: ReactNode;
active: boolean;
onClick: () => void;
onDelete?: () => void;
rightSlot?: ReactNode;
environment: Environment | null;
outerRightSlot?: ReactNode;
environment: Environment;
deleteEnvironment: ((environment: Environment) => void) | null;
duplicateEnvironment: ((environment: Environment) => void) | null;
}) {
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
@@ -287,49 +342,88 @@ function SidebarButton({
justify="start"
onClick={onClick}
onContextMenu={handleContextMenu}
rightSlot={rightSlot}
>
{children}
</Button>
{rightSlot}
{outerRightSlot}
</div>
{environment != null && (
<ContextMenu
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />,
onSelect: async () => {
const name = await showPrompt({
id: 'rename-environment',
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
label: 'Name',
confirmText: 'Save',
placeholder: 'New Name',
defaultValue: environment.name,
});
if (name == null) return;
await patchModel(environment, { name });
},
<ContextMenu
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
hidden: environment.base,
onSelect: async () => {
const name = await showPrompt({
id: 'rename-environment',
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
label: 'Name',
confirmText: 'Save',
placeholder: 'New Name',
defaultValue: environment.name,
});
if (name == null) return;
await patchModel(environment, { name });
},
{
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: async () => {
await deleteModelWithConfirm(environment);
onDelete?.();
},
},
...((duplicateEnvironment
? [
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => {
duplicateEnvironment?.(environment);
},
},
]
: []) as DropdownItem[]),
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: (
<IconTooltip
content={
<>
Sharable environments will be included in Directory Sync or data export. It is
recommended to encrypt all variable values within sharable environments to
prevent accidentally leaking secrets.
</>
}
/>
),
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
]}
/>
)}
},
...((deleteEnvironment
? [
{
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => {
deleteEnvironment(environment);
},
},
]
: []) as DropdownItem[]),
]}
/>
</>
);
}
const sharableTooltip = (
<IconTooltip
icon="eye"
content="This environment will be included in Directory Sync and data exports"
/>
);

View File

@@ -0,0 +1,68 @@
import type { ErrorInfo, ReactNode } from 'react';
import { Component, useEffect } from 'react';
import { showDialog } from '../lib/dialog';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import RouteError from './RouteError';
interface ErrorBoundaryProps {
name: string;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn('Error caught by ErrorBoundary:', error, info);
}
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => {
showDialog({
id: 'error-boundary',
render: () => <RouteError error={this.state.error} />,
});
}}
>
Show
</Button>
</Banner>
);
}
return this.props.children;
}
}
export function ErrorBoundaryTestThrow() {
useEffect(() => {
throw new Error('test error');
});
return <div>Hello</div>;
}

View File

@@ -1,5 +1,5 @@
import { save } from '@tauri-apps/plugin-dialog';
import type { Workspace} from '@yaakapp-internal/models';
import type { Workspace } from '@yaakapp-internal/models';
import { workspacesAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useCallback, useMemo, useState } from 'react';
@@ -41,12 +41,12 @@ function ExportDataDialogContent({
allWorkspaces: Workspace[];
activeWorkspace: Workspace;
}) {
const [includeEnvironments, setIncludeEnvironments] = useState<boolean>(false);
const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState<boolean>(false);
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
[activeWorkspace.id]: true,
});
// Put active workspace first
// Put the active workspace first
const workspaces = useMemo(
() => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)],
[activeWorkspace, allWorkspaces],
@@ -73,11 +73,11 @@ function ExportDataDialogContent({
await invokeCmd('cmd_export_data', {
workspaceIds: ids,
exportPath,
includeEnvironments: includeEnvironments,
includePrivateEnvironments: includePrivateEnvironments,
});
onHide();
onSuccess(exportPath);
}, [includeEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
@@ -129,9 +129,10 @@ function ExportDataDialogContent({
<summary className="px-3 py-2">Extra Settings</summary>
<div className="px-3 pb-2">
<Checkbox
checked={includeEnvironments}
onChange={setIncludeEnvironments}
title="Include environments"
checked={includePrivateEnvironments}
onChange={setIncludePrivateEnvironments}
title="Include private environments"
help='Environments marked as "sharable" will be exported by default'
/>
</div>
</details>

View File

@@ -1,36 +1,91 @@
import { foldersAtom, patchModel } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo, useState } from 'react';
import { useAuthTab } from '../hooks/useAuthTab';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
interface Props {
folderId: string | null;
tab?: FolderSettingsTab;
}
export function FolderSettingsDialog({ folderId }: Props) {
const TAB_AUTH = 'auth';
const TAB_HEADERS = 'headers';
const TAB_GENERAL = 'general';
export type FolderSettingsTab = typeof TAB_AUTH | typeof TAB_HEADERS | typeof TAB_GENERAL;
export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId);
const folder = folders.find((f) => f.id === folderId) ?? null;
const [activeTab, setActiveTab] = useState<string>(tab ?? TAB_GENERAL);
const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder);
const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return [];
return [
{
value: TAB_GENERAL,
label: 'General',
},
...authTab,
...headersTab,
];
}, [authTab, folder, headersTab]);
if (folder == null) return null;
return (
<VStack space={3} className="pb-3">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Folder Settings"
className="px-1.5 pb-2"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="pt-3 overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="pt-3 overflow-y-auto h-full px-4">
<VStack space={3} className="pb-3">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="min-h-[10rem] border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
</VStack>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="min-h-[10rem] border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
</VStack>
</TabContent>
<TabContent value={TAB_HEADERS} className="pt-3 overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
headers={folder.headers}
onChange={(headers) => patchModel(folder, { headers })}
stateKey={`headers.${folder.id}`}
/>
</TabContent>
</Tabs>
);
}

View File

@@ -70,8 +70,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
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);
}

View File

@@ -343,9 +343,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
color: 'success',
label: 'Open Workspace Settings',
leftSlot: <Icon icon="settings" />,
onSelect() {
openWorkspaceSettings.mutate();
},
onSelect: openWorkspaceSettings,
},
{ type: 'separator' },
{

View File

@@ -1,6 +1,6 @@
import type { EditorView } from '@codemirror/view';
import type { HttpRequest } from '@yaakapp-internal/models';
import { updateSchema } from 'cm6-graphql';
import type { EditorView } from 'codemirror';
import { formatSdl } from 'format-graphql';
import { useEffect, useMemo, useRef } from 'react';
@@ -60,7 +60,7 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
// Refetch the schema when the URL changes
useEffect(() => {
if (editorViewRef.current === null) return;
if (editorViewRef.current == null) return;
updateSchema(editorViewRef.current, schema ?? undefined);
}, [schema]);

View File

@@ -1,8 +1,8 @@
import { jsonLanguage } from '@codemirror/lang-json';
import { linter } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import type { GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import {
handleRefresh,
jsonCompletion,

View File

@@ -1,10 +1,12 @@
import { type GrpcMetadataEntry, type GrpcRequest, patchModel } from '@yaakapp-internal/models';
import { type GrpcRequest, type HttpRequestHeader, patchModel } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { useAuthTab } from '../hooks/useAuthTab';
import { useContainerSize } from '../hooks/useContainerQuery';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { resolvedModelName } from '../lib/resolvedModelName';
@@ -12,13 +14,13 @@ import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { PlainInput } from './core/PlainInput';
import { RadioDropdown } from './core/RadioDropdown';
import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { GrpcEditor } from './GrpcEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
import { UrlBar } from './UrlBar';
@@ -64,7 +66,9 @@ export function GrpcRequestPane({
onCancel,
onSend,
}: Props) {
const authentication = useHttpAuthenticationSummaries();
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
const inheritedHeaders = useInheritedHeaders(activeRequest);
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'grpcRequestActiveTabs',
@@ -130,42 +134,15 @@ export function GrpcRequestPane({
const tabs: TabItem[] = useMemo(
() => [
{ value: TAB_MESSAGE, label: 'Message' },
{
value: TAB_AUTH,
label: 'Auth',
options: {
value: activeRequest.authenticationType,
items: [
...authentication.map((a) => ({
label: a.label || 'UNKNOWN',
shortLabel: a.shortLabel,
value: a.name,
})),
{ type: 'separator' },
{ label: 'No Authentication', shortLabel: 'Auth', value: null },
],
onChange: async (authenticationType) => {
let authentication: GrpcRequest['authentication'] = activeRequest.authentication;
if (activeRequest.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
await patchModel(activeRequest, {
authenticationType,
authentication,
});
},
},
},
{ value: TAB_METADATA, label: 'Metadata' },
...metadataTab,
...authTab,
{
value: TAB_DESCRIPTION,
label: 'Info',
rightSlot: activeRequest.description && <CountBadge count={true} />,
},
],
[activeRequest, authentication],
[activeRequest.description, authTab, metadataTab],
);
const activeTab = activeTabs?.[activeRequest.id];
@@ -177,7 +154,7 @@ export function GrpcRequestPane({
);
const handleMetadataChange = useCallback(
(metadata: GrpcMetadataEntry[]) => patchModel(activeRequest, { metadata }),
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
[activeRequest],
);
@@ -307,17 +284,15 @@ export function GrpcRequestPane({
/>
</TabContent>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor request={activeRequest} />
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_METADATA}>
<PairOrBulkEditor
preferenceName="grpc_metadata"
valueAutocompleteVariables
nameAutocompleteVariables
pairs={activeRequest.metadata}
onChange={handleMetadataChange}
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={forceUpdateKey}
stateKey={`grpc_metadata.${activeRequest.id}`}
headers={activeRequest.metadata}
stateKey={`headers.${activeRequest.id}`}
onChange={handleMetadataChange}
/>
</TabContent>
<TabContent value={TAB_DESCRIPTION}>

View File

@@ -1,10 +1,9 @@
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { useAtomValue , useSetAtom } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCopy } from '../hooks/useCopy';
import {
activeGrpcConnectionAtom,
activeGrpcConnections,
@@ -12,20 +11,22 @@ import {
useGrpcEvents,
} from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { copyToClipboard } from '../lib/copy';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Editor } from './core/Editor/Editor';
import { HotKeyList } from './core/HotKeyList';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { JsonAttributeTree } from './core/JsonAttributeTree';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { LoadingIcon } from './core/LoadingIcon';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
import { HotKeyList } from './core/HotKeyList';
interface Props {
style?: CSSProperties;
@@ -48,7 +49,6 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
const events = useGrpcEvents(activeConnection?.id ?? null);
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
const copy = useCopy();
const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null,
@@ -93,27 +93,29 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
/>
</div>
</HStack>
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
<ErrorBoundary name="GRPC Events">
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
</ErrorBoundary>
</div>
)
}
@@ -136,7 +138,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(activeEvent.content)}
onClick={() => copyToClipboard(activeEvent.content)}
/>
</div>
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
@@ -161,7 +163,13 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
</div>
</VStack>
) : (
<JsonAttributeTree attrValue={JSON.parse(activeEvent?.content ?? '{}')} />
<Editor
language="json"
defaultValue={activeEvent.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</>
) : (

View File

@@ -1,9 +1,8 @@
import { settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { HTMLAttributes, ReactNode } from 'react';
import React from 'react';
import { useOsInfo } from '../hooks/useOsInfo';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import React, { useMemo } from 'react';
import { useStoplightsVisible } from '../hooks/useStoplightsVisible';
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from '../lib/constants';
import { WindowControls } from './WindowControls';
@@ -23,27 +22,42 @@ export function HeaderSize({
onlyXWindowControl,
children,
}: HeaderSizeProps) {
const osInfo = useOsInfo();
const settings = useAtomValue(settingsAtom);
const stoplightsVisible = useStoplightsVisible();
const finalStyle = useMemo<CSSProperties>(() => {
const s = { ...style };
// Set the height (use min-height because scaling font size may make it larger
if (size === 'md') s.minHeight = HEADER_SIZE_MD;
if (size === 'lg') s.minHeight = HEADER_SIZE_LG;
// Add large padding for window controls
if (stoplightsVisible && !ignoreControlsSpacing) {
s.paddingLeft = 72 / settings.interfaceScale;
} else if (!stoplightsVisible && !ignoreControlsSpacing && !settings.hideWindowControls) {
s.paddingRight = WINDOW_CONTROLS_WIDTH;
}
return s;
}, [
ignoreControlsSpacing,
settings.hideWindowControls,
settings.interfaceScale,
size,
stoplightsVisible,
style,
]);
return (
<div
data-tauri-drag-region
style={{
...style,
// Add padding for macOS stoplights, but keep it the same width (account for the interface scale)
paddingLeft:
stoplightsVisible && !ignoreControlsSpacing ? 72 / settings.interfaceScale : undefined,
...(size === 'md' ? { minHeight: HEADER_SIZE_MD } : {}),
...(size === 'lg' ? { minHeight: HEADER_SIZE_LG } : {}),
...(osInfo.osType === 'macos' || ignoreControlsSpacing
? { paddingRight: '2px' }
: { paddingLeft: '2px', paddingRight: WINDOW_CONTROLS_WIDTH }),
}}
style={finalStyle}
className={classNames(
className,
'px-1', // Give it some space on either end
'pt-[1px]', // Make up for bottom border
'select-none relative',
'pt-[1px] w-full border-b border-border-subtle min-w-0',
'w-full border-b border-border-subtle min-w-0',
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}

View File

@@ -5,36 +5,85 @@ import { connections } from '../lib/data/connections';
import { encodings } from '../lib/data/encodings';
import { headerNames } from '../lib/data/headerNames';
import { mimeTypes } from '../lib/data/mimetypes';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { InputProps } from './core/Input';
import type { Pair, PairEditorProps } from './core/PairEditor';
import { ensurePairId, PairEditorRow } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { HStack } from './core/Stacks';
type Props = {
forceUpdateKey: string;
headers: HttpRequestHeader[];
inheritedHeaders?: HttpRequestHeader[];
stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void;
label?: string;
};
export function HeadersEditor({ stateKey, headers, onChange, forceUpdateKey }: Props) {
export function HeadersEditor({
stateKey,
headers,
inheritedHeaders,
onChange,
forceUpdateKey,
}: Props) {
const validInheritedHeaders =
inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
return (
<PairOrBulkEditor
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="Header-Name"
nameValidate={validateHttpHeader}
onChange={onChange}
pairs={headers}
preferenceName="headers"
stateKey={stateKey}
valueType={valueType}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions
valueAutocompleteVariables
/>
<div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{validInheritedHeaders.length > 0 ? (
<Banner className="!py-0 mb-1.5 border-dashed" color="secondary">
<details>
<summary className="py-1.5 text-sm !cursor-default !select-none opacity-70 hover:opacity-100">
<HStack>
Inherited <CountBadge count={validInheritedHeaders.length} />
</HStack>
</summary>
<div className="pb-2">
{validInheritedHeaders?.map((pair, i) => (
<PairEditorRow
key={pair.id + '.' + i}
index={i}
disabled
disableDrag
className="py-1"
onChange={() => {}}
onEnd={() => {}}
onMove={() => {}}
pair={ensurePairId(pair)}
stateKey={null}
nameAutocompleteFunctions
nameAutocompleteVariables
valueAutocompleteFunctions
valueAutocompleteVariables
/>
))}
</div>
</details>
</Banner>
) : (
<span />
)}
<PairOrBulkEditor
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="Header-Name"
nameValidate={validateHttpHeader}
onChange={onChange}
pairs={headers}
preferenceName="headers"
stateKey={stateKey}
valueType={valueType}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions
valueAutocompleteVariables
/>
</div>
);
}
@@ -51,14 +100,14 @@ const headerOptionsMap: Record<string, string[]> = {
const valueType = (pair: Pair): InputProps['type'] => {
const name = pair.name.toLowerCase().trim();
if (
name.includes('authorization') ||
name.includes('api-key') ||
name.includes('access-token') ||
name.includes('auth') ||
name.includes('secret') ||
name.includes('token') ||
name === 'cookie' ||
name === 'set-cookie'
name.includes('authorization') ||
name.includes('api-key') ||
name.includes('access-token') ||
name.includes('auth') ||
name.includes('secret') ||
name.includes('token') ||
name === 'cookie' ||
name === 'set-cookie'
) {
return 'password';
} else {

View File

@@ -1,34 +1,84 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import React, { useCallback } from 'react';
import { openFolderSettings } from '../commands/openFolderSettings';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig';
import { useInheritedAuthentication } from '../hooks/useInheritedAuthentication';
import { resolvedModelName } from '../lib/resolvedModelName';
import { Checkbox } from './core/Checkbox';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { HStack } from './core/Stacks';
import { DynamicForm } from './DynamicForm';
import { EmptyStateText } from './EmptyStateText';
interface Props {
request: HttpRequest | GrpcRequest | WebsocketRequest;
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
}
export function HttpAuthenticationEditor({ request }: Props) {
export function HttpAuthenticationEditor({ model }: Props) {
const inheritedAuth = useInheritedAuthentication(model);
const authConfig = useHttpAuthenticationConfig(
request.authenticationType,
request.authentication,
request.id,
model.authenticationType,
model.authentication,
model.id,
);
const handleChange = useCallback(
(authentication: Record<string, boolean>) => patchModel(request, { authentication }),
[request],
async (authentication: Record<string, boolean>) => await patchModel(model, { authentication }),
[model],
);
if (authConfig.data == null) {
return <EmptyStateText>No Authentication {request.authenticationType}</EmptyStateText>;
if (model.authenticationType === 'none') {
return <EmptyStateText>No authentication</EmptyStateText>;
}
if (model.authenticationType != null && authConfig.data == null) {
return (
<EmptyStateText>
Unknown authentication <InlineCode>{authConfig.data}</InlineCode>
</EmptyStateText>
);
}
if (inheritedAuth == null) {
return <EmptyStateText>Authentication not configured</EmptyStateText>;
}
if (inheritedAuth.authenticationType === 'none') {
return <EmptyStateText>No authentication</EmptyStateText>;
}
const wasAuthInherited = inheritedAuth?.id !== model.id;
if (wasAuthInherited) {
const name = resolvedModelName(inheritedAuth);
const cta = inheritedAuth.model === 'workspace' ? 'Workspace' : name;
return (
<EmptyStateText>
<p>
Inherited from{' '}
<button
className="underline hover:text-text"
onClick={() => {
if (inheritedAuth.model === 'folder') openFolderSettings(inheritedAuth.id, 'auth');
else openWorkspaceSettings('auth');
}}
>
{cta}
</button>
</p>
</EmptyStateText>
);
}
return (
@@ -36,17 +86,17 @@ export function HttpAuthenticationEditor({ request }: Props) {
<HStack space={2} className="mb-2" alignItems="center">
<Checkbox
className="w-full"
checked={!request.authentication.disabled}
onChange={(disabled) => handleChange({ ...request.authentication, disabled: !disabled })}
checked={!model.authentication.disabled}
onChange={(disabled) => handleChange({ ...model.authentication, disabled: !disabled })}
title="Enabled"
/>
{authConfig.data.actions && authConfig.data.actions.length > 0 && (
{authConfig.data?.actions && authConfig.data.actions.length > 0 && (
<Dropdown
items={authConfig.data.actions.map(
(a): DropdownItem => ({
label: a.label,
leftSlot: a.icon ? <Icon icon={a.icon} /> : null,
onSelect: () => a.call(request),
onSelect: () => a.call(model),
}),
)}
>
@@ -55,12 +105,12 @@ export function HttpAuthenticationEditor({ request }: Props) {
)}
</HStack>
<DynamicForm
disabled={request.authentication.disabled}
disabled={model.authentication.disabled}
autocompleteVariables
autocompleteFunctions
stateKey={`auth.${request.id}.${request.authenticationType}`}
inputs={authConfig.data.args}
data={request.authentication}
stateKey={`auth.${model.id}.${model.authenticationType}`}
inputs={authConfig.data?.args ?? []}
data={model.authentication}
onChange={handleChange}
/>
</div>

View File

@@ -6,13 +6,15 @@ import { atom, useAtomValue } from 'jotai';
import type { CSSProperties } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { useAuthTab } from '../hooks/useAuthTab';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useImportCurl } from '../hooks/useImportCurl';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { deepEqualAtom } from '../lib/atoms';
@@ -85,7 +87,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const contentType = getContentTypeFromHeaders(activeRequest.headers);
const authentication = useHttpAuthenticationSummaries();
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest);
const handleContentTypeChange = useCallback(
async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => {
@@ -214,42 +218,21 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
rightSlot: <CountBadge count={urlParameterPairs.length} />,
label: 'Params',
},
{
value: TAB_HEADERS,
label: 'Headers',
rightSlot: <CountBadge count={activeRequest.headers.filter((h) => h.name).length} />,
},
{
value: TAB_AUTH,
label: 'Auth',
options: {
value: activeRequest.authenticationType,
items: [
...authentication.map((a) => ({
label: a.label || 'UNKNOWN',
shortLabel: a.shortLabel,
value: a.name,
})),
{ type: 'separator' },
{ label: 'No Authentication', shortLabel: 'Auth', value: null },
],
onChange: async (authenticationType) => {
let authentication: HttpRequest['authentication'] = activeRequest.authentication;
if (activeRequest.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
await patchModel(activeRequest, { authenticationType, authentication });
},
},
},
...headersTab,
...authTab,
{
value: TAB_DESCRIPTION,
label: 'Info',
},
],
[activeRequest, authentication, handleContentTypeChange, numParams, urlParameterPairs.length],
[
activeRequest,
authTab,
handleContentTypeChange,
headersTab,
numParams,
urlParameterPairs.length,
],
);
const { mutate: sendRequest } = useSendAnyHttpRequest();
@@ -372,10 +355,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
tabListClassName="mt-2 !mb-1.5"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor request={activeRequest} />
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_HEADERS}>
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}

View File

@@ -30,6 +30,7 @@ import { ImageViewer } from './responseViewers/ImageViewer';
import { PdfViewer } from './responseViewers/PdfViewer';
import { SvgViewer } from './responseViewers/SvgViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { ErrorBoundary } from './ErrorBoundary';
interface Props {
style?: CSSProperties;
@@ -155,35 +156,37 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
tabListClassName="mt-1.5"
>
<TabContent value={TAB_BODY}>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? (
<EmptyStateText>
<LoadingIcon size="xl" className="text-text-subtlest" />
</EmptyStateText>
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
<EmptyStateText>Empty </EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<SvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === 'pretty'}
/>
)}
</ConfirmLargeResponse>
<ErrorBoundary name="Http Response Viewer">
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? (
<EmptyStateText>
<LoadingIcon size="xl" className="text-text-subtlest" />
</EmptyStateText>
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
<EmptyStateText>Empty </EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<SvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === 'pretty'}
/>
)}
</ConfirmLargeResponse>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />

View File

@@ -1,12 +1,11 @@
import type { ReactNode } from 'react';
import { useAppInfo } from '../hooks/useAppInfo';
import { appInfo } from '../lib/appInfo';
interface Props {
children: ReactNode;
}
export function IsDev({ children }: Props) {
const appInfo = useAppInfo();
if (!appInfo.isDev) {
return null;
}

View File

@@ -2,11 +2,10 @@ 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 { appInfo } from '../lib/appInfo';
import { useLicenseConfirmation } from '../hooks/useLicenseConfirmation';
import { BadgeButton } from './core/BadgeButton';
import type { ButtonProps } from './core/Button';
import { SettingsTab } from './Settings/SettingsTab';
const details: Record<
LicenseCheckStatus['type'],
@@ -28,7 +27,7 @@ export function LicenseBadge() {
if (check.error) {
return (
<BadgeButton color="danger" onClick={() => openSettings.mutate(SettingsTab.License)}>
<BadgeButton color="danger" onClick={() => openSettings.mutate('license')}>
License Error
</BadgeButton>
);
@@ -64,7 +63,7 @@ export function LicenseBadge() {
hasDismissedTrial: true,
}));
}
openSettings.mutate(SettingsTab.License);
openSettings.mutate('license');
}}
>
{detail.label}

View File

@@ -1,13 +1,16 @@
import remarkGfm from 'remark-gfm';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ErrorBoundary } from './ErrorBoundary';
import { Prose } from './Prose';
export function Markdown({ children, className }: { children: string; className?: string }) {
return (
<Prose className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
<ErrorBoundary name="Markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
</ErrorBoundary>
</Prose>
);
}

View File

@@ -32,6 +32,7 @@ export function RedirectToLatestWorkspace() {
request_id: requestId,
};
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: '/workspaces/$workspaceId', params, search });
})();
}, [recentWorkspaces, workspaces, workspaces.length]);

View File

@@ -32,8 +32,8 @@ export function ResizeHandle({
className={classNames(
className,
'group z-10 flex select-none',
// 'bg-fg-info', // For debugging
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
// 'bg-info', // For debugging
vertical ? 'w-full h-2 cursor-row-resize' : 'h-full w-2 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',

View File

@@ -3,7 +3,7 @@ import { FormattedError } from './core/FormattedError';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
export default function RouteError({ error }: { error: unknown; reset: () => void }) {
export default function RouteError({ error }: { error: unknown }) {
console.log('Error', error);
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -17,7 +17,7 @@ export default function RouteError({ error }: { error: unknown; reset: () => voi
<FormattedError>
{message}
{stack && (
<details className="mt-3 select-autotext-xs">
<details className="mt-3 select-auto text-xs">
<summary className="!cursor-default !select-none">Stack Trace</summary>
<div className="mt-2 text-xs">{stack}</div>
</details>

View File

@@ -80,7 +80,7 @@ export function SelectFile({
<>
{filePath && (
<IconButton
size={size}
size={size === 'auto' ? 'md' : size}
variant="border"
icon="x"
title={'Unset ' + itemLabel}

View File

@@ -1,9 +1,9 @@
import { useSearch } from '@tanstack/react-router';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import React, { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { useOsInfo } from '../../hooks/useOsInfo';
import { capitalize } from '../../lib/capitalize';
import { HStack } from '../core/Stacks';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
@@ -13,24 +13,22 @@ import { SettingsGeneral } from './SettingsGeneral';
import { SettingsLicense } from './SettingsLicense';
import { SettingsPlugins } from './SettingsPlugins';
import { SettingsProxy } from './SettingsProxy';
import { SettingsTab } from './SettingsTab';
interface Props {
hide?: () => void;
}
const tabs = [
SettingsTab.General,
SettingsTab.Appearance,
SettingsTab.Proxy,
SettingsTab.Plugins,
SettingsTab.License,
];
const TAB_GENERAL = 'general';
const TAB_APPEARANCE = 'appearance';
const TAB_PROXY = 'proxy';
const TAB_PLUGINS = 'plugins';
const TAB_LICENSE = 'license';
const tabs = [TAB_GENERAL, TAB_APPEARANCE, TAB_PROXY, TAB_PLUGINS, TAB_LICENSE] as const;
export type SettingsTab = (typeof tabs)[number];
export default function Settings({ hide }: Props) {
const osInfo = useOsInfo();
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
const [tab, setTab] = useState<string>(tabFromQuery ?? SettingsTab.General);
const [tab, setTab] = useState<string | undefined>(tabFromQuery);
// Close settings window on escape
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
@@ -61,9 +59,7 @@ export default function Settings({ hide }: Props) {
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(osInfo?.osType === 'macos' ? 'text-center' : 'pl-2')}>
Settings
</div>
<div className={classNames(type() === 'macos' ? 'text-center' : 'pl-2')}>Settings</div>
</HStack>
</HeaderSize>
)}
@@ -74,19 +70,19 @@ export default function Settings({ hide }: Props) {
onChangeValue={setTab}
tabs={tabs.map((value) => ({ value, label: capitalize(value) }))}
>
<TabContent value={SettingsTab.General} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_GENERAL} className="pt-3 overflow-y-auto h-full px-4">
<SettingsGeneral />
</TabContent>
<TabContent value={SettingsTab.Appearance} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_APPEARANCE} className="pt-3 overflow-y-auto h-full px-4">
<SettingsAppearance />
</TabContent>
<TabContent value={SettingsTab.Plugins} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_PLUGINS} className="pt-3 overflow-y-auto h-full px-4">
<SettingsPlugins />
</TabContent>
<TabContent value={SettingsTab.Proxy} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_PROXY} className="pt-3 overflow-y-auto h-full px-4">
<SettingsProxy />
</TabContent>
<TabContent value={SettingsTab.License} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_LICENSE} className="pt-3 overflow-y-auto h-full px-4">
<SettingsLicense />
</TabContent>
</Tabs>

View File

@@ -18,6 +18,7 @@ import type { SelectProps } from '../core/Select';
import { Select } from '../core/Select';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
import { type } from '@tauri-apps/plugin-os';
const fontSizeOptions = [
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
@@ -122,6 +123,15 @@ export function SettingsAppearance() {
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
/>
{type() !== 'macos' && (
<Checkbox
checked={settings.hideWindowControls}
title="Hide Window Controls"
help="Hide the close/maximize/minimize controls on Windows or Linux"
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
/>
)}
<Separator className="my-4" />
<Select

View File

@@ -3,7 +3,7 @@ import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import React from 'react';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useAppInfo } from '../../hooks/useAppInfo';
import { appInfo } from '../../lib/appInfo';
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';
import { revealInFinderText } from '../../lib/reveal';
import { Checkbox } from '../core/Checkbox';
@@ -18,7 +18,6 @@ import { VStack } from '../core/Stacks';
export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const appInfo = useAppInfo();
const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) {

View File

@@ -24,6 +24,7 @@ export function SettingsProxy() {
} else if (v === 'enabled') {
await patchModel(settings, {
proxy: {
disabled: false,
type: 'enabled',
http: '',
https: '',
@@ -42,16 +43,42 @@ export function SettingsProxy() {
/>
{settings.proxy?.type === 'enabled' && (
<VStack space={1.5}>
<HStack space={1.5} className="mt-3">
<Checkbox
className="my-3"
checked={!settings.proxy.disabled}
title="Enable proxy"
help="Use this to temporarily disable the proxy without losing the configuration"
onChange={async (enabled) => {
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const https = proxy?.type === 'enabled' ? proxy.https : '';
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
const disabled = !enabled;
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
<HStack space={1.5}>
<PlainInput
size="sm"
label="HTTP"
placeholder="localhost:9090"
defaultValue={settings.proxy?.http}
onChange={async (http) => {
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
const auth = settings.proxy?.type === 'enabled' ? settings.proxy.auth : null;
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
const { proxy } = settings;
const https = proxy?.type === 'enabled' ? proxy.https : '';
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
await patchModel(settings, {
proxy: {
type: 'enabled',
http,
https,
auth,
disabled,
},
});
}}
/>
<PlainInput
@@ -60,9 +87,13 @@ export function SettingsProxy() {
placeholder="localhost:9090"
defaultValue={settings.proxy?.https}
onChange={async (https) => {
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
const auth = settings.proxy?.type === 'enabled' ? settings.proxy.auth : null;
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
</HStack>
@@ -71,10 +102,14 @@ export function SettingsProxy() {
checked={settings.proxy.auth != null}
title="Enable authentication"
onChange={async (enabled) => {
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const https = proxy?.type === 'enabled' ? proxy.https : '';
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
const auth = enabled ? { user: '', password: '' } : null;
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
@@ -87,12 +122,15 @@ export function SettingsProxy() {
placeholder="myUser"
defaultValue={settings.proxy.auth.user}
onChange={async (user) => {
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
const password =
settings.proxy?.type === 'enabled' ? (settings.proxy.auth?.password ?? '') : '';
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const https = proxy?.type === 'enabled' ? proxy.https : '';
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
const password = proxy?.type === 'enabled' ? (proxy.auth?.password ?? '') : '';
const auth = { user, password };
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
<PlainInput
@@ -102,12 +140,15 @@ export function SettingsProxy() {
placeholder="s3cretPassw0rd"
defaultValue={settings.proxy.auth.password}
onChange={async (password) => {
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
const user =
settings.proxy?.type === 'enabled' ? (settings.proxy.auth?.user ?? '') : '';
const { proxy } = settings;
const http = proxy?.type === 'enabled' ? proxy.http : '';
const https = proxy?.type === 'enabled' ? proxy.https : '';
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
const user = proxy?.type === 'enabled' ? (proxy.auth?.user ?? '') : '';
const auth = { user, password };
await patchModel(settings, { proxy: { type: 'enabled', http, https, auth } });
await patchModel(settings, {
proxy: { type: 'enabled', http, https, auth, disabled },
});
}}
/>
</HStack>

View File

@@ -1,8 +0,0 @@
export enum SettingsTab {
General = 'general',
Proxy = 'proxy',
Appearance = 'appearance',
Plugins = 'plugins',
License = 'license',
}

View File

@@ -2,7 +2,7 @@ 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';
import { appInfo } from '../lib/appInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
@@ -13,12 +13,10 @@ 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();
const exportData = useExportData();
const appInfo = useAppInfo();
const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates();
const { check } = useLicense();
@@ -64,7 +62,7 @@ export function SettingsDropdown() {
color: 'success',
hidden: check.data == null || check.data.type === 'commercial_use',
leftSlot: <Icon icon="circle_dollar_sign" />,
onSelect: () => openSettings.mutate(SettingsTab.License),
onSelect: () => openSettings.mutate('license'),
},
{
label: 'Check for Updates',
@@ -74,13 +72,13 @@ export function SettingsDropdown() {
{
label: 'Feedback',
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl('https://yaak.app/feedback'),
},
{
label: 'Changelog',
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
},
]}

View File

@@ -4,6 +4,7 @@ import React, { type ReactNode } from 'react';
import { hideToast, toastsAtom } from '../lib/toast';
import { Toast, type ToastProps } from './core/Toast';
import { Portal } from './Portal';
import { ErrorBoundary } from './ErrorBoundary';
export type ToastInstance = {
id: string;
@@ -22,16 +23,17 @@ export const Toasts = () => {
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<Toast
key={uniqueKey}
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
<Toast
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
</ErrorBoundary>
);
})}
</AnimatePresence>

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