Compare commits

..

15 Commits

Author SHA1 Message Date
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
39 changed files with 897 additions and 396 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

@@ -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,6 +1,6 @@
// 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, };

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

@@ -1,10 +1,14 @@
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_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 +18,48 @@ 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
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.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

@@ -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
@@ -394,6 +399,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

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))
}
};

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),
base: e.parentId === workspaceId ? true : false,
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

@@ -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

@@ -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

@@ -54,7 +54,7 @@ export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt
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, };

View File

@@ -1,9 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment } from "./gen_models.js";
import type { Folder } from "./gen_models.js";
import type { GrpcRequest } from "./gen_models.js";
import type { HttpRequest } from "./gen_models.js";
import type { WebsocketRequest } from "./gen_models.js";
import type { Workspace } from "./gen_models.js";
import type { Environment } from "./gen_models";
import type { Folder } from "./gen_models";
import type { GrpcRequest } from "./gen_models";
import type { HttpRequest } from "./gen_models";
import type { WebsocketRequest } from "./gen_models";
import type { Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };

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();
@@ -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

@@ -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>,

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

@@ -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,
@@ -257,12 +257,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

@@ -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

@@ -258,7 +258,11 @@ const EnvironmentEditor = function ({
)}
</Heading>
{activeEnvironment.public && promptToEncrypt && (
<DismissibleBanner id={activeEnvironment.id} color="notice" className="mr-3">
<DismissibleBanner
id={`warn-unencrypted-${activeEnvironment.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>

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

@@ -25,6 +25,7 @@ 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';
interface Props {
@@ -92,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>
)
}

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,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

@@ -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

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

@@ -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>

View File

@@ -27,6 +27,7 @@ import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentWebsocketConnectionsDropdown } from './RecentWebsocketConnectionsDropdown';
interface Props {
@@ -93,27 +94,29 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
/>
</HStack>
</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="Websocket 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>
)
}

View File

@@ -41,6 +41,7 @@ import { Sidebar } from './sidebar/Sidebar';
import { SidebarActions } from './sidebar/SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader';
import { ErrorBoundary } from './ErrorBoundary';
const side = { gridArea: 'side' };
const head = { gridArea: 'head' };
@@ -149,13 +150,17 @@ export function Workspace() {
<HeaderSize size="lg" className="border-transparent">
<SidebarActions />
</HeaderSize>
<Sidebar />
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
</ErrorBoundary>
</m.div>
</Overlay>
) : (
<>
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
<Sidebar className="border-r border-border-subtle" />
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
<ResizeHandle
className="-translate-x-3"
@@ -175,7 +180,9 @@ export function Workspace() {
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<WorkspaceBody />
<ErrorBoundary name="Workspace Body">
<WorkspaceBody />
</ErrorBoundary>
</div>
);
}

View File

@@ -250,8 +250,8 @@ function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
const helpAfterEncryption = (
<p>
this key is used for any encryption used for this workspace. It is stored securely using your OS
keychain, but it is recommended to back it up. If you share this workspace with others,
you&apos;ll need to send them this key to access any encrypted values.
The following key is used for encryption operations within this workspace. It is stored securely
using your OS keychain, but it is recommended to back it up. If you share this workspace with
others, you&apos;ll need to send them this key to access any encrypted values.
</p>
);

View File

@@ -37,6 +37,7 @@ import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon';
import { Separator } from './Separator';
import { HStack, VStack } from './Stacks';
import { ErrorBoundary } from '../ErrorBoundary';
export type DropdownItemSeparator = {
type: 'separator';
@@ -202,17 +203,19 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
return (
<>
{child}
<Menu
ref={menuRef}
showTriangle
triggerRef={buttonRef}
fullWidth={fullWidth}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect ?? null}
onClose={() => setIsOpen(false)}
isOpen={isOpen}
/>
<ErrorBoundary name={`Dropdown Menu`}>
<Menu
ref={menuRef}
showTriangle
triggerRef={buttonRef}
fullWidth={fullWidth}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect ?? null}
onClose={() => setIsOpen(false)}
isOpen={isOpen}
/>
</ErrorBoundary>
</>
);
});

View File

@@ -13,6 +13,7 @@ import {
} from 'react';
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { copyToClipboard } from '../../lib/copy';
import {
analyzeTemplate,
convertTemplateToInsecure,
@@ -30,7 +31,6 @@ import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { Label } from './Label';
import { HStack } from './Stacks';
import { copyToClipboard } from '../../lib/copy';
export type InputProps = Pick<
EditorProps,
@@ -127,13 +127,29 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
const lastWindowFocus = useRef<number>(0);
useEffect(() => {
const fn = () => (lastWindowFocus.current = Date.now());
window.addEventListener('focus', fn);
return () => {
window.removeEventListener('focus', fn);
};
}, []);
const handleFocus = useCallback(() => {
if (readOnly) return;
// Select all text of input when it's focused to match standard browser behavior.
// This should not, however, select when the input is focused due to a window focus event, so
// we handle that case as well.
const windowJustFocused = Date.now() - lastWindowFocus.current < 200;
if (!windowJustFocused) {
editorRef.current?.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
}
setFocused(true);
// Select all text on focus
editorRef.current?.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
onFocus?.();
}, [onFocus, readOnly]);

View File

@@ -5,6 +5,7 @@ import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
import { HStack } from '../Stacks';
import { ErrorBoundary } from '../../ErrorBoundary';
export type TabItem =
| {
@@ -153,12 +154,14 @@ export const TabContent = memo(function TabContent({
className,
}: TabContentProps) {
return (
<div
tabIndex={-1}
data-tab={value}
className={classNames(className, 'tab-content', 'hidden w-full h-full')}
>
{children}
</div>
<ErrorBoundary name={`Tab ${value}`}>
<div
tabIndex={-1}
data-tab={value}
className={classNames(className, 'tab-content', 'hidden w-full h-full')}
>
{children}
</div>
</ErrorBoundary>
);
});

View File

@@ -5,15 +5,13 @@ export function useWindowFocus() {
const [visible, setVisible] = useState(true);
useEffect(() => {
let unsub: undefined | (() => void) = undefined;
getCurrentWebviewWindow()
.onFocusChanged((e) => {
setVisible(e.payload);
})
.then((fn) => {
unsub = fn;
});
return () => unsub?.();
const unlisten = getCurrentWebviewWindow().onFocusChanged((e) => {
setVisible(e.payload);
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
return visible;