Compare commits

..

12 Commits

Author SHA1 Message Date
Gregory Schier
a258a80fbd Prevent auth from adding lone ? to URL
https://feedback.yaak.app/p/using-inherited-api-key-causes-a-question-mark-to-be
2025-07-23 17:20:17 -07:00
Gregory Schier
1b90842d30 Regex template function 2025-07-23 13:33:58 -07:00
Carter Costic
f1acb3c925 Merge pull request #245
* Attach cookies to WS Upgrade

* Merge branch 'main' into main

* Move reqwest_cookie_store to workspace dep
2025-07-23 13:14:15 -07:00
Gregory Schier
28630bbb6c Remove template as default value 2025-07-23 12:46:26 -07:00
Gregory Schier
86a09642e7 Rename template-function-datetime 2025-07-23 12:42:54 -07:00
Song
0b38948826 add template-function-datetime (#244) 2025-07-23 12:41:24 -07:00
Gregory Schier
c09083ddec Fix up export dialog 2025-07-21 14:45:13 -07:00
Gregory Schier
44ee020383 Plugins menu item and link to run button 2025-07-21 14:38:29 -07:00
Gregory Schier
c609d0ff0c Fix GraphQL schema getting nuked on codemirror language refresh 2025-07-21 14:17:36 -07:00
Gregory Schier
7eb3f123c6 Add run button link 2025-07-21 07:47:29 -07:00
Gregory Schier
2bd8a50df4 Tweak tab padding 2025-07-21 07:45:11 -07:00
Gregory Schier
178cc88efb Fix Authenticatin typo
https://feedback.yaak.app/p/authentication-misspelled-in-request-auth-tooltip
2025-07-21 07:39:54 -07:00
38 changed files with 782 additions and 164 deletions

19
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"plugins/importer-postman",
"plugins/importer-yaak",
"plugins/template-function-cookie",
"plugins/template-function-timestamp",
"plugins/template-function-encode",
"plugins/template-function-fs",
"plugins/template-function-hash",
@@ -4174,6 +4175,10 @@
"resolved": "plugins/template-function-response",
"link": true
},
"node_modules/@yaak/template-function-timestamp": {
"resolved": "plugins/template-function-timestamp",
"link": true
},
"node_modules/@yaak/template-function-uuid": {
"resolved": "plugins/template-function-uuid",
"link": true
@@ -18585,6 +18590,13 @@
"name": "@yaak/template-function-cookie",
"version": "0.1.0"
},
"plugins/template-function-datetime": {
"version": "0.1.0",
"extraneous": true,
"dependencies": {
"date-fns": "^4.1.0"
}
},
"plugins/template-function-encode": {
"name": "@yaak/template-function-encode",
"version": "0.1.0"
@@ -18631,6 +18643,13 @@
"@types/jsonpath": "^0.2.4"
}
},
"plugins/template-function-timestamp": {
"name": "@yaak/template-function-timestamp",
"version": "0.1.0",
"dependencies": {
"date-fns": "^4.1.0"
}
},
"plugins/template-function-uuid": {
"name": "@yaak/template-function-uuid",
"version": "0.1.0",

View File

@@ -25,6 +25,7 @@
"plugins/importer-postman",
"plugins/importer-yaak",
"plugins/template-function-cookie",
"plugins/template-function-timestamp",
"plugins/template-function-encode",
"plugins/template-function-fs",
"plugins/template-function-hash",

View File

@@ -36,7 +36,7 @@ export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };
export type CallTemplateFunctionResponse = { value: string | null, };
export type CallTemplateFunctionResponse = { value: string | null, error?: string, };
export type CloseWindowRequest = { label: string, };

View File

@@ -308,15 +308,27 @@ export class PluginInstance {
const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name);
if (typeof fn?.onRender === 'function') {
applyFormInputDefaults(fn.args, payload.args.values);
const result = await fn.onRender(ctx, payload.args);
this.#sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: result ?? null,
},
replyId,
);
try {
const result = await fn.onRender(ctx, payload.args);
this.#sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: result ?? null,
},
replyId,
);
} catch (err) {
this.#sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: null,
error: `${err}`.replace(/^Error:\s*/g, ''),
},
replyId,
);
}
return;
}
}

View File

@@ -1,32 +1,74 @@
import type { TemplateFunctionArg } from '@yaakapp-internal/plugins';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
const inputArg: TemplateFunctionArg = {
type: 'text',
name: 'input',
label: 'Input Text',
multiLine: true,
};
const regexArg: TemplateFunctionArg = {
type: 'text',
name: 'regex',
label: 'Regular Expression',
placeholder: '\\w+',
defaultValue: '.*',
description:
'A JavaScript regular expression. Use a capture group to reference parts of the match in the replacement.',
};
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'regex.match',
description: 'Extract',
args: [
{
type: 'text',
name: 'regex',
label: 'Regular Expression',
placeholder: '^\\w+=(?<value>\\w*)$',
defaultValue: '^(.*)$',
description:
'A JavaScript regular expression, evaluated using the Node.js RegExp engine. Capture groups or named groups can be used to extract values.',
},
{ type: 'text', name: 'input', label: 'Input Text', multiLine: true },
],
description: 'Extract text using a regular expression',
args: [inputArg, regexArg],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.regex || !args.values.input) return '';
const input = String(args.values.input ?? '');
const regex = new RegExp(String(args.values.regex ?? ''));
const input = String(args.values.input);
const regex = new RegExp(String(args.values.regex));
const match = input.match(regex);
return match?.groups
? (Object.values(match.groups)[0] ?? '')
: (match?.[1] ?? match?.[0] ?? '');
},
},
{
name: 'regex.replace',
description: 'Replace text using a regular expression',
args: [
inputArg,
regexArg,
{
type: 'text',
name: 'replacement',
label: 'Replacement Text',
placeholder: 'hello $1',
description:
'The replacement text. Use $1, $2, ... to reference capture groups or $& to reference the entire match.',
},
{
type: 'text',
name: 'flags',
label: 'Flags',
placeholder: 'g',
defaultValue: 'g',
optional: true,
description:
'Regular expression flags (g for global, i for case-insensitive, m for multiline, etc.)',
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const input = String(args.values.input ?? '');
const replacement = String(args.values.replacement ?? '');
const flags = String(args.values.flags || '');
const regex = String(args.values.regex);
if (!regex) return '';
return input.replace(new RegExp(String(args.values.regex), flags), replacement);
},
},
],
};

View File

@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import type { Context } from '@yaakapp/api';
import { plugin } from '../src';
describe('regex.match', () => {
const matchFunction = plugin.templateFunctions!.find(f => f.name === 'regex.match');
it('should exist', () => {
expect(matchFunction).toBeDefined();
});
it('should extract first capture group', async () => {
const result = await matchFunction!.onRender({} as Context, {
values: {
regex: 'Hello (\\w+)',
input: 'Hello World',
},
purpose: 'send',
});
expect(result).toBe('World');
});
it('should extract named capture group', async () => {
const result = await matchFunction!.onRender({} as Context, {
values: {
regex: 'Hello (?<name>\\w+)',
input: 'Hello World',
},
purpose: 'send',
});
expect(result).toBe('World');
});
it('should return full match when no capture groups', async () => {
const result = await matchFunction!.onRender({} as Context, {
values: {
regex: 'Hello \\w+',
input: 'Hello World'
},
purpose: 'send',
});
expect(result).toBe('Hello World');
});
it('should return empty string when no match', async () => {
const result = await matchFunction!.onRender({} as Context, {
values: {
regex: 'Goodbye',
input: 'Hello World'
},
purpose: 'send',
});
expect(result).toBe('');
});
it('should return empty string when regex is empty', async () => {
const result = await matchFunction!.onRender({} as Context, {
values: {
regex: '',
input: 'Hello World'
},
purpose: 'send',
});
expect(result).toBe('');
});
it('should return empty string when input is empty', async () => {
const result = await matchFunction!.onRender({} as Context, {
values: {
regex: 'Hello',
input: ''
},
purpose: 'send',
});
expect(result).toBe('');
});
});
describe('regex.replace', () => {
const replaceFunction = plugin.templateFunctions!.find(f => f.name === 'regex.replace');
it('should exist', () => {
expect(replaceFunction).toBeDefined();
});
it('should replace one occurrence by default', async () => {
const result = await replaceFunction!.onRender({} as Context, {
values: {
regex: 'o',
input: 'Hello World',
replacement: 'a'
},
purpose: 'send',
});
expect(result).toBe('Hella World');
});
it('should replace with capture groups', async () => {
const result = await replaceFunction!.onRender({} as Context, {
values: {
regex: '(\\w+) (\\w+)',
input: 'Hello World',
replacement: '$2 $1'
},
purpose: 'send',
});
expect(result).toBe('World Hello');
});
it('should replace with full match reference', async () => {
const result = await replaceFunction!.onRender({} as Context, {
values: {
regex: 'World',
input: 'Hello World',
replacement: '[$&]'
},
purpose: 'send',
});
expect(result).toBe('Hello [World]');
});
it('should respect flags parameter', async () => {
const result = await replaceFunction!.onRender({} as Context, {
values: {
regex: 'hello',
input: 'Hello World',
replacement: 'Hi',
flags: 'i'
},
purpose: 'send',
});
expect(result).toBe('Hi World');
});
it('should handle empty replacement', async () => {
const result = await replaceFunction!.onRender({} as Context, {
values: {
regex: 'World',
input: 'Hello World',
replacement: ''
},
purpose: 'send',
});
expect(result).toBe('Hello ');
});
it('should return original input when no match', async () => {
const result = await replaceFunction!.onRender({} as Context, {
values: {
regex: 'Goodbye',
input: 'Hello World',
replacement: 'Hi'
},
purpose: 'send',
});
expect(result).toBe('Hello World');
});
it('should return empty string when regex is empty', async () => {
const result = await replaceFunction!.onRender({} as Context, {
values: {
regex: '',
input: 'Hello World',
replacement: 'Hi'
},
purpose: 'send',
});
expect(result).toBe('');
});
it('should return empty string when input is empty', async () => {
const result = await replaceFunction!.onRender({} as Context, {
values: {
regex: 'Hello',
input: '',
replacement: 'Hi'
},
purpose: 'send',
});
expect(result).toBe('');
});
it('should throw on invalid regex', async () => {
const fn = replaceFunction!.onRender({} as Context, {
values: {
regex: '[',
input: 'Hello World',
replacement: 'Hi'
},
purpose: 'send',
});
await expect(fn).rejects.toThrow('Invalid regular expression: /[/: Unterminated character class');
});
});

View File

@@ -0,0 +1,13 @@
{
"name": "@yaak/template-function-timestamp",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"date-fns": "^4.1.0"
}
}

View File

@@ -0,0 +1,155 @@
import type { TemplateFunctionArg } from '@yaakapp-internal/plugins';
import type { PluginDefinition } from '@yaakapp/api';
import {
addDays,
addHours,
addMinutes,
addMonths,
addSeconds,
addYears,
format as formatDate,
isValid,
parseISO,
subDays,
subHours,
subMinutes,
subMonths,
subSeconds,
subYears,
} from 'date-fns';
const dateArg: TemplateFunctionArg = {
type: 'text',
name: 'date',
label: 'Timestamp',
optional: true,
description: 'Can be a timestamp in milliseconds, ISO string, or anything parseable by JS `new Date()`',
placeholder: new Date().toISOString(),
};
const expressionArg: TemplateFunctionArg = {
type: 'text',
name: 'expression',
label: 'Expression',
description: "Modification expression (eg. '-5d +2h 3m'). Available units: y, M, d, h, m, s",
optional: true,
placeholder: '-5d +2h 3m',
};
const formatArg: TemplateFunctionArg = {
name: 'format',
label: 'Format String',
description: "Format string to describe the output (eg. 'yyyy-MM-dd at HH:mm:ss')",
optional: true,
placeholder: 'yyyy-MM-dd HH:mm:ss',
type: 'text',
};
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'timestamp.unix',
description: 'Get the current timestamp in seconds',
args: [],
onRender: async () => String(Math.floor(Date.now() / 1000)),
},
{
name: 'timestamp.unixMillis',
description: 'Get the current timestamp in milliseconds',
args: [],
onRender: async () => String(Date.now()),
},
{
name: 'timestamp.iso8601',
description: 'Get the current date in ISO8601 format',
args: [],
onRender: async () => new Date().toISOString(),
},
{
name: 'timestamp.format',
description: 'Format a date using a dayjs-compatible format string',
args: [dateArg, formatArg],
onRender: async (_ctx, args) => formatDatetime(args.values),
},
{
name: 'timestamp.offset',
description: 'Get the offset of a date based on an expression',
args: [dateArg, expressionArg],
onRender: async (_ctx, args) => calculateDatetime(args.values),
},
],
};
function applyDateOp(d: Date, sign: string, amount: number, unit: string): Date {
switch (unit) {
case 'y':
return sign === '-' ? subYears(d, amount) : addYears(d, amount);
case 'M':
return sign === '-' ? subMonths(d, amount) : addMonths(d, amount);
case 'd':
return sign === '-' ? subDays(d, amount) : addDays(d, amount);
case 'h':
return sign === '-' ? subHours(d, amount) : addHours(d, amount);
case 'm':
return sign === '-' ? subMinutes(d, amount) : addMinutes(d, amount);
case 's':
return sign === '-' ? subSeconds(d, amount) : addSeconds(d, amount);
default:
throw new Error(`Invalid data calculation unit: ${unit}`);
}
}
function parseOp(op: string): { sign: string; amount: number; unit: string } | null {
const match = op.match(/^([+-]?)(\d+)([yMdhms])$/);
if (!match) {
throw new Error(`Invalid date expression: ${op}`);
}
const [, sign, amount, unit] = match;
if (!unit) return null;
return { sign: sign ?? '+', amount: Number(amount ?? 0), unit };
}
function parseDateString(date: string): Date {
if (!date.trim()) {
return new Date();
}
const isoDate = parseISO(date);
if (isValid(isoDate)) {
return isoDate;
}
const jsDate = /^\d+(\.\d+)?$/.test(date) ? new Date(Number(date)) : new Date(date);
if (isValid(jsDate)) {
return jsDate;
}
throw new Error(`Invalid date: ${date}`);
}
export function calculateDatetime(args: { date?: string; expression?: string }): string {
const { date, expression } = args;
let jsDate = parseDateString(date ?? '');
if (expression) {
const ops = String(expression)
.split(' ')
.map((s) => s.trim())
.filter(Boolean);
for (const op of ops) {
const parsed = parseOp(op);
if (parsed) {
jsDate = applyDateOp(jsDate, parsed.sign, parsed.amount, parsed.unit);
}
}
}
return jsDate.toISOString();
}
export function formatDatetime(args: { date?: string; format?: string }): string {
const { date, format = 'yyyy-MM-dd HH:mm:ss' } = args;
const d = parseDateString(date ?? '');
return formatDate(d, String(format));
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { calculateDatetime, formatDatetime } from '../src';
describe('formatDatetime', () => {
it('returns formatted current date', () => {
const result = formatDatetime({});
expect(result).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
it('returns formatted specific date', () => {
const result = formatDatetime({ date: '2025-07-13T12:34:56' });
expect(result).toBe('2025-07-13 12:34:56');
});
it('returns formatted specific timestamp', () => {
const result = formatDatetime({ date: '1752435296000' });
expect(result).toBe('2025-07-13 12:34:56');
});
it('returns formatted specific timestamp with decimals', () => {
const result = formatDatetime({ date: '1752435296000.19' });
expect(result).toBe('2025-07-13 12:34:56');
});
it('returns formatted date with custom output', () => {
const result = formatDatetime({ date: '2025-07-13T12:34:56', format: 'dd/MM/yyyy' });
expect(result).toBe('13/07/2025');
});
it('handles invalid date gracefully', () => {
expect(() => formatDatetime({ date: 'invalid-date' })).toThrow('Invalid date: invalid-date');
});
});
describe('calculateDatetime', () => {
it('returns ISO string for current date', () => {
const result = calculateDatetime({});
expect(result).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('returns ISO string for specific date', () => {
const result = calculateDatetime({ date: '2025-07-13T12:34:56Z' });
expect(result).toBe('2025-07-13T12:34:56.000Z');
});
it('applies calc operations', () => {
const result = calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1d 2h' });
expect(result).toBe('2025-07-14T14:00:00.000Z');
});
it('applies negative calc operations', () => {
const result = calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '-1d -2h 1m' });
expect(result).toBe('2025-07-12T10:01:00.000Z');
});
it('throws error for invalid unit', () => {
expect(() => calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1x' })).toThrow(
'Invalid date expression: +1x',
);
});
it('throws error for invalid unit weird', () => {
expect(() => calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1&#^%' })).toThrow(
'Invalid date expression: +1&#^%',
);
});
it('throws error for bad expression', () => {
expect(() =>
calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: 'bad expr' }),
).toThrow('Invalid date expression: bad');
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

1
src-tauri/Cargo.lock generated
View File

@@ -8103,6 +8103,7 @@ dependencies = [
"futures-util",
"log",
"md5 0.7.0",
"reqwest_cookie_store",
"serde",
"serde_json",
"tauri",

View File

@@ -50,7 +50,7 @@ md5 = "0.8.0"
mime_guess = "2.0.5"
rand = "0.9.0"
reqwest = { workspace = true, features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json", "rustls-tls-manual-roots-no-provider", "socks"] }
reqwest_cookie_store = "0.8.0"
reqwest_cookie_store = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
@@ -97,6 +97,7 @@ tauri-plugin-shell = "2.3.0"
tokio = "1.45.1"
thiserror = "2.0.12"
ts-rs = "11.0.1"
reqwest_cookie_store = "0.8.0"
rustls = { version = "0.23.27", default-features = false }
rustls-platform-verifier = "0.6.0"
sha2 = "0.10.9"

View File

@@ -488,10 +488,11 @@ pub async fn send_http_request<R: Runtime>(
};
}
let mut query_pairs = sendable_req.url_mut().query_pairs_mut();
for p in plugin_result.set_query_parameters.unwrap_or_default() {
println!("Adding query parameter: {:?}", p);
query_pairs.append_pair(&p.name, &p.value);
if let Some(params) = plugin_result.set_query_parameters {
let mut query_pairs = sendable_req.url_mut().query_pairs_mut();
for p in params {
query_pairs.append_pair(&p.name, &p.value);
}
}
}
}

View File

@@ -53,6 +53,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
"import-data" => {
let mut file_path = query_map.get("path").map(|s| s.to_owned());
let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string());
_ = window.set_focus();
if let Some(file_url) = query_map.get("url") {
let confirmed_import = app_handle
@@ -96,7 +97,6 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
};
let results = import_data(window, &file_path).await?;
_ = window.set_focus();
window.emit(
"show_toast",
ShowToastRequest {

View File

@@ -36,7 +36,7 @@ export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };
export type CallTemplateFunctionResponse = { value: string | null, };
export type CallTemplateFunctionResponse = { value: string | null, error?: string, };
export type CloseWindowRequest = { label: string, };

View File

@@ -974,6 +974,8 @@ pub struct CallTemplateFunctionRequest {
#[ts(export, export_to = "gen_events.ts")]
pub struct CallTemplateFunctionResponse {
pub value: Option<String>,
#[ts(optional)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]

View File

@@ -6,12 +6,13 @@ use crate::events::{
BootRequest, CallGrpcRequestActionRequest, CallHttpAuthenticationActionArgs,
CallHttpAuthenticationActionRequest, CallHttpAuthenticationRequest,
CallHttpAuthenticationResponse, CallHttpRequestActionRequest, CallTemplateFunctionArgs,
CallTemplateFunctionRequest, CallTemplateFunctionResponse, EmptyPayload, FilterRequest,
FilterResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigRequest,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, GetThemesRequest,
GetThemesResponse, ImportRequest, ImportResponse, InternalEvent, InternalEventPayload,
JsonPrimitive, PluginWindowContext, RenderPurpose,
CallTemplateFunctionRequest, CallTemplateFunctionResponse, EmptyPayload, ErrorResponse,
FilterRequest, FilterResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionsResponse, GetThemesRequest, GetThemesResponse, ImportRequest,
ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginWindowContext,
RenderPurpose,
};
use crate::native_template_functions::template_function_secure;
use crate::nodejs::start_nodejs_plugin_runtime;
@@ -644,7 +645,7 @@ impl PluginManager {
info!("Not applying disabled auth {:?}", auth_name);
return Ok(CallHttpAuthenticationResponse {
set_headers: None,
set_query_parameters: None
set_query_parameters: None,
});
}
@@ -689,16 +690,25 @@ impl PluginManager {
.map_err(|e| RenderError(format!("Failed to call template function {e:}")))?;
let value = events.into_iter().find_map(|e| match e.payload {
// Error returned
InternalEventPayload::CallTemplateFunctionResponse(CallTemplateFunctionResponse {
error: Some(error),
..
}) => Some(Err(error)),
// Value or null returned
InternalEventPayload::CallTemplateFunctionResponse(CallTemplateFunctionResponse {
value,
}) => Some(value),
..
}) => Some(Ok(value.unwrap_or_default())),
// Generic error returned
InternalEventPayload::ErrorResponse(ErrorResponse { error }) => Some(Err(error)),
_ => None,
});
match value {
None => Err(RenderError(format!("Template function {fn_name}(…) not found "))),
Some(Some(v)) => Ok(v), // Plugin returned string
Some(None) => Ok("".to_string()), // Plugin returned null
Some(Ok(v)) => Ok(v),
Some(Err(e)) => Err(RenderError(e)),
}
}

View File

@@ -9,6 +9,7 @@ publish = false
futures-util = "0.3.31"
log = "0.4.20"
md5 = "0.7.0"
reqwest_cookie_store = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tauri = { workspace = true }

View File

@@ -2,6 +2,7 @@ use crate::error::Result;
use crate::manager::WebsocketManager;
use crate::render::render_websocket_request;
use crate::resolve::resolve_websocket_request;
use log::debug;
use log::{info, warn};
use std::str::FromStr;
use tauri::http::{HeaderMap, HeaderName};
@@ -293,18 +294,53 @@ pub(crate) async fn connect<R: Runtime>(
_ => continue,
};
}
let mut query_pairs = url.query_pairs_mut();
for p in plugin_result.set_query_parameters.unwrap_or_default() {
query_pairs.append_pair(&p.name, &p.value);
if let Some(params) = plugin_result.set_query_parameters {
let mut query_pairs = url.query_pairs_mut();
for p in params {
query_pairs.append_pair(&p.name, &p.value);
}
}
}
}
// TODO: Handle cookies
let _cookie_jar = match cookie_jar_id {
Some(id) => Some(app_handle.db().get_cookie_jar(id)?),
None => None,
};
// Add cookies to WS HTTP Upgrade
if let Some(id) = cookie_jar_id {
let cookie_jar = app_handle.db().get_cookie_jar(id)?;
let cookies = cookie_jar
.cookies
.iter()
.filter_map(|cookie| {
// HACK: same as in src-tauri/src/http_request.rs
let json_cookie = serde_json::to_value(cookie).ok()?;
match serde_json::from_value(json_cookie) {
Ok(cookie) => Some(Ok(cookie)),
Err(_e) => None,
}
})
.collect::<Vec<Result<_>>>();
let store = reqwest_cookie_store::CookieStore::from_cookies(cookies, true)?;
// Convert WS URL -> HTTP URL bc reqwest_cookie_store's `get_request_values`
// strictly matches based on Path/HttpOnly/Secure attributes even though WS upgrades are HTTP requests
let http_url = convert_ws_url_to_http(&url);
let pairs: Vec<_> = store.get_request_values(&http_url).collect();
debug!("Inserting {} cookies into WS upgrade to {}", pairs.len(), url);
let cookie_header_value = pairs
.into_iter()
.map(|(name, value)| format!("{}={}", name, value))
.collect::<Vec<_>>()
.join("; ");
if !cookie_header_value.is_empty() {
headers.insert(
HeaderName::from_static("cookie"),
HeaderValue::from_str(&cookie_header_value).unwrap(),
);
}
}
let (receive_tx, mut receive_rx) = mpsc::channel::<Message>(128);
let mut ws_manager = ws_manager.lock().await;
@@ -337,7 +373,7 @@ pub(crate) async fn connect<R: Runtime>(
Err(e) => {
return Ok(app_handle.db().upsert_websocket_connection(
&WebsocketConnection {
error: Some(format!("{e:?}")),
error: Some(e.to_string()),
state: WebsocketConnectionState::Closed,
..connection
},
@@ -448,3 +484,23 @@ pub(crate) async fn connect<R: Runtime>(
Ok(connection)
}
/// Convert WS URL to HTTP URL for cookie filtering
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
fn convert_ws_url_to_http(ws_url: &Url) -> Url {
let mut http_url = ws_url.clone();
match ws_url.scheme() {
"ws" => {
http_url.set_scheme("http").expect("Failed to set http scheme");
}
"wss" => {
http_url.set_scheme("https").expect("Failed to set https scheme");
}
_ => {
// Already HTTP/HTTPS, no conversion needed
}
}
http_url
}

View File

@@ -10,6 +10,7 @@ import { invokeCmd } from '../lib/tauri';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { DetailsBanner } from './core/DetailsBanner';
import { Link } from './core/Link';
import { HStack, VStack } from './core/Stacks';
interface Props {
@@ -83,69 +84,81 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<VStack space={3} className="w-full mb-3 px-4">
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="w-6 min-w-0 py-2 text-left pl-1">
<Checkbox
checked={!allSelected && !noneSelected ? 'indeterminate' : allSelected}
hideLabel
title="All workspaces"
onChange={handleToggleAll}
/>
</th>
<th className="py-2 text-left pl-4" onClick={handleToggleAll}>
Workspace
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{workspaces.map((w) => (
<tr key={w.id}>
<td className="min-w-0 py-1 pl-1">
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
<VStack space={3} className="overflow-auto px-5 pb-6">
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="w-6 min-w-0 py-2 text-left pl-1">
<Checkbox
checked={selectedWorkspaces[w.id] ?? false}
title={w.name}
checked={!allSelected && !noneSelected ? 'indeterminate' : allSelected}
hideLabel
onChange={() =>
title="All workspaces"
onChange={handleToggleAll}
/>
</th>
<th className="py-2 text-left pl-4" onClick={handleToggleAll}>
Workspace
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{workspaces.map((w) => (
<tr key={w.id}>
<td className="min-w-0 py-1 pl-1">
<Checkbox
checked={selectedWorkspaces[w.id] ?? false}
title={w.name}
hideLabel
onChange={() =>
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
}
/>
</td>
<td
className="py-1 pl-4 text whitespace-nowrap overflow-x-auto hide-scrollbars"
onClick={() =>
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
}
/>
</td>
<td
className="py-1 pl-4 text whitespace-nowrap overflow-x-auto hide-scrollbars"
onClick={() => setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))}
>
{w.name} {w.id === activeWorkspace.id ? '(current workspace)' : ''}
</td>
</tr>
))}
</tbody>
</table>
<DetailsBanner color="secondary" open summary="Extra Settings">
<Checkbox
checked={includePrivateEnvironments}
onChange={setIncludePrivateEnvironments}
title="Include private environments"
help='Environments marked as "sharable" will be exported by default'
/>
</DetailsBanner>
<HStack space={2} justifyContent="end">
<Button className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{' '}
{pluralizeCount('Workspace', numSelected, { omitSingle: true, noneWord: 'Nothing' })}
</Button>
</HStack>
</VStack>
>
{w.name} {w.id === activeWorkspace.id ? '(current workspace)' : ''}
</td>
</tr>
))}
</tbody>
</table>
<DetailsBanner color="secondary" open summary="Extra Settings">
<Checkbox
checked={includePrivateEnvironments}
onChange={setIncludePrivateEnvironments}
title="Include private environments"
help='Environments marked as "sharable" will be exported by default'
/>
</DetailsBanner>
</VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
<div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
Create Run Button
</Link>
</div>
<HStack space={2} justifyContent="end">
<Button size="sm" className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
size="sm"
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{' '}
{pluralizeCount('Workspace', numSelected, { omitSingle: true, noneWord: 'Nothing' })}
</Button>
</HStack>
</footer>
</div>
);
}

View File

@@ -69,7 +69,7 @@ export default function Settings({ hide }: Props) {
layout="horizontal"
value={tab}
addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border"
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
onChangeValue={setTab}
tabs={tabs.map((value) => ({ value, label: capitalize(value) }))}

View File

@@ -46,7 +46,7 @@ export function SettingsPlugins() {
addBorders
tabListClassName="!-ml-3"
tabs={[
{ label: 'Marketplace', value: 'search' },
{ label: 'Discover', value: 'search' },
{
label: 'Installed',
value: 'installed',

View File

@@ -42,6 +42,12 @@ export function SettingsDropdown() {
});
},
},
{
label: 'Plugins',
leftSlot: <Icon icon="puzzle" />,
onSelect: () => openSettings.mutate('plugins'),
},
{ type: 'separator', label: 'Share Workspace(s)' },
{
label: 'Import Data',
leftSlot: <Icon icon="folder_input" />,
@@ -52,6 +58,11 @@ export function SettingsDropdown() {
leftSlot: <Icon icon="folder_output" />,
onSelect: () => exportData.mutate(),
},
{
label: 'Create Run Button',
leftSlot: <Icon icon="rocket" />,
onSelect: () => openUrl('https://yaak.app/button/new'),
},
{ type: 'separator', label: `Yaak v${appInfo.version}` },
{
label: 'Purchase License',

View File

@@ -11,10 +11,10 @@ import {
import { useToggle } from '../hooks/useToggle';
import { convertTemplateToInsecure } from '../lib/encryption';
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { LoadingIcon } from './core/LoadingIcon';
import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks';
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm';
@@ -178,7 +178,10 @@ function InitializedTemplateFunctionDialog({
{enablePreview && (
<VStack className="w-full" space={1}>
<HStack space={0.5}>
<div className="text-sm text-text-subtle">Rendered Preview</div>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isPending && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
@@ -191,26 +194,24 @@ function InitializedTemplateFunctionDialog({
)}
/>
</HStack>
{rendered.error || tagText.error ? (
<Banner color="danger">{`${rendered.error || tagText.error}`}</Banner>
) : (
<InlineCode
className={classNames(
'whitespace-pre select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
tooLarge && 'italic text-danger',
)}
>
{dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
'too large to preview'
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
)}
<InlineCode
className={classNames(
'whitespace-pre-wrap select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
tooLarge && 'italic text-danger',
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, '')}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">------ sensitive values hidden ------</span>
) : tooLarge ? (
'too large to preview'
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
</VStack>
)}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">

View File

@@ -21,10 +21,12 @@ export function DetailsBanner({ className, color, summary, children, ...extraPro
'w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0',
'border-t-transparent border-b-transparent border-l-text-subtle',
)}
></div>
/>
{summary}
</summary>
<div className="mt-1.5">
{children}
</div>
</details>
</Banner>
);

View File

@@ -12,9 +12,10 @@ import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates';
import classNames from 'classnames';
import type { GraphQLSchema } from 'graphql';
import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { MutableRefObject, ReactNode } from 'react';
import type { ReactNode, RefObject } from 'react';
import {
Children,
cloneElement,
@@ -77,6 +78,7 @@ export interface EditorProps {
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | 'pairs' | 'url';
graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void;
onChange?: (value: string) => void;
onFocus?: () => void;
@@ -115,6 +117,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
format,
heightMode,
hideGutter,
graphQLSchema,
language,
onBlur,
onChange,
@@ -276,7 +279,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const show = () =>
showDialog({
id: 'template-function-' + Math.random(), // Allow multiple at once
size: 'sm',
size: 'md',
title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description,
render: ({ hide }) => (
@@ -374,6 +377,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [
@@ -386,6 +390,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickPathParameter,
completionOptions,
useTemplating,
graphQLSchema,
]);
// Initialize the editor when ref mounts
@@ -408,6 +413,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
});
const extensions = [
languageCompartment.of(langExt),
@@ -595,12 +601,12 @@ function getExtensions({
}: Pick<EditorProps, 'singleLine' | 'readOnly' | 'hideGutter'> & {
stateKey: EditorProps['stateKey'];
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onPaste: MutableRefObject<EditorProps['onPaste']>;
onPasteOverwrite: MutableRefObject<EditorProps['onPasteOverwrite']>;
onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
onKeyDown: MutableRefObject<EditorProps['onKeyDown']>;
onChange: RefObject<EditorProps['onChange']>;
onPaste: RefObject<EditorProps['onPaste']>;
onPasteOverwrite: RefObject<EditorProps['onPasteOverwrite']>;
onFocus: RefObject<EditorProps['onFocus']>;
onBlur: RefObject<EditorProps['onBlur']>;
onKeyDown: RefObject<EditorProps['onKeyDown']>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =

View File

@@ -37,6 +37,7 @@ import {
import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp-internal/models';
import { graphql } from 'cm6-graphql';
import type { GraphQLSchema } from 'graphql';
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
import { jotaiStore } from '../../../lib/jotai';
import { renderMarkdown } from '../../../lib/markdown';
@@ -106,6 +107,7 @@ export function getLanguageExtension({
onClickMissingVariable,
onClickPathParameter,
completionOptions,
graphQLSchema,
}: {
useTemplating: boolean;
environmentVariables: EnvironmentVariable[];
@@ -113,6 +115,7 @@ export function getLanguageExtension({
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, 'language' | 'autocomplete'>) {
const extraExtensions: Extension[] = [];
@@ -128,7 +131,7 @@ export function getLanguageExtension({
// GraphQL is a special exception
if (language === 'graphql') {
return [
graphql(undefined, {
graphql(graphQLSchema ?? undefined, {
async onCompletionInfoRender(gqlCompletionItem): Promise<Node | null> {
if (!gqlCompletionItem.documentation) return null;
const innerHTML = await renderMarkdown(gqlCompletionItem.documentation);

View File

@@ -92,7 +92,9 @@ const icons = {
plug: lucide.Plug,
plus: lucide.PlusIcon,
plus_circle: lucide.PlusCircleIcon,
puzzle: lucide.PuzzleIcon,
refresh: lucide.RefreshCwIcon,
rocket: lucide.RocketIcon,
save: lucide.SaveIcon,
search: lucide.SearchIcon,
send_horizontal: lucide.SendHorizonalIcon,
@@ -140,7 +142,7 @@ export const Icon = memo(function Icon({
title={title}
className={classNames(
className,
!spin && 'transform-cpu',
!spin && 'transform-gpu',
spin && 'animate-spin',
'flex-shrink-0',
size === 'xl' && 'h-6 w-6',

View File

@@ -9,6 +9,7 @@ type Props = Omit<TooltipProps, 'children'> & {
iconSize?: IconProps['size'];
iconColor?: IconProps['color'];
className?: string;
tabIndex?: number;
};
export function IconTooltip({

View File

@@ -519,7 +519,7 @@ function EncryptionInput({
color="danger"
size={props.size}
className="text-sm"
rightSlot={<IconTooltip content={state.error} icon="alert_triangle" />}
rightSlot={<IconTooltip tabIndex={-1} content={state.error} icon="alert_triangle" />}
onClick={() => {
setupOrConfigureEncryption();
}}

View File

@@ -39,7 +39,7 @@ export function Label({
({tag})
</span>
))}
{help && <IconTooltip content={help} />}
{help && <IconTooltip tabIndex={-1} content={help} />}
</label>
);
}

View File

@@ -84,7 +84,7 @@ export function Tabs({
tabListClassName,
addBorders && '!-ml-1',
'flex items-center hide-scrollbars mb-2',
layout === 'horizontal' && 'h-full overflow-auto p-2',
layout === 'horizontal' && 'h-full overflow-auto p-2 -mr-2',
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
// Give space for button focus states within overflow boundary.
layout === 'vertical' && 'py-1 -ml-5 pl-3 pr-1',
@@ -108,7 +108,7 @@ export function Tabs({
: layout === 'vertical'
? 'border-border-subtle'
: 'border-transparent',
layout === 'horizontal' && 'flex justify-between',
layout === 'horizontal' && 'flex justify-between min-w-[10rem]',
);
if ('options' in t) {

View File

@@ -1,10 +1,9 @@
import type { EditorView } from '@codemirror/view';
import type { HttpRequest } from '@yaakapp-internal/models';
import { updateSchema } from 'cm6-graphql';
import { formatSdl } from 'format-graphql';
import { useAtom } from 'jotai';
import { useEffect, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import { useLocalStorage } from 'react-use';
import { useIntrospectGraphQL } from '../../hooks/useIntrospectGraphQL';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
@@ -63,12 +62,6 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
onChange(newBody);
};
// Refetch the schema when the URL changes
useEffect(() => {
if (editorViewRef.current == null) return;
updateSchema(editorViewRef.current, schema ?? undefined);
}, [schema]);
const actions = useMemo<EditorProps['actions']>(
() => [
<div key="actions" className="flex flex-row !opacity-100 !shadow">
@@ -201,6 +194,7 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
<Editor
language="graphql"
heightMode="auto"
graphQLSchema={schema}
format={formatSdl}
defaultValue={currentBody.query}
onChange={handleChangeQuery}

View File

@@ -37,7 +37,7 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
<IconTooltip
icon="magic_wand"
iconSize="xs"
content="Authenticatin was inherited from an ancestor"
content="Authentication was inherited from an ancestor"
/>
</HStack>
) : (

View File

@@ -21,6 +21,10 @@ export function useFilterResponse({
filter,
})) as FilterResponse;
if (result.error) {
console.log("Failed to filter response:", result.error);
}
return result;
},
});

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useAtomValue } from 'jotai';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import { invokeCmd } from '../lib/tauri';
import { useActiveEnvironment } from './useActiveEnvironment';
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
@@ -8,10 +9,9 @@ export function useRenderTemplate(template: string) {
const workspaceId = useAtomValue(activeWorkspaceIdAtom) ?? 'n/a';
const environmentId = useActiveEnvironment()?.id ?? null;
return useQuery<string>({
placeholderData: (prev) => prev, // Keep previous data on refetch
refetchOnWindowFocus: false,
queryKey: ['render_template', template, workspaceId, environmentId],
queryFn: () => renderTemplate({ template, workspaceId, environmentId }),
queryFn: () => minPromiseMillis(renderTemplate({ template, workspaceId, environmentId }), 200),
});
}

View File

@@ -4,7 +4,6 @@ import { invokeCmd } from '../lib/tauri';
export function useTemplateTokensToString(tokens: Tokens) {
return useQuery<string>({
placeholderData: (prev) => prev, // Keep previous data on refetch
refetchOnWindowFocus: false,
queryKey: ['template_tokens_to_string', tokens],
queryFn: () => templateTokensToString(tokens),

View File

@@ -33,7 +33,7 @@ export function resolvedModelName(r: AnyModel | null): string {
}
// Strip unnecessary protocol
const withoutProto = withoutVariables.replace(/^https?:\/\//, '');
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, '');
return withoutProto;
}