Compare commits

...

10 Commits

Author SHA1 Message Date
Gregory Schier
2e144f064d Fix syntax highlighting 2025-05-21 08:26:15 -07:00
Gregory Schier
d8b1cadae6 Fix model deletion 2025-05-21 08:25:12 -07:00
Gregory Schier
c2f9760d08 Fix template parsing 2025-05-21 08:18:09 -07:00
Gregory Schier
a4c600cb48 Lint errors 2025-05-20 08:15:19 -07:00
Gregory Schier
bc3a5e3e58 Include license status in notification endpoint 2025-05-20 08:13:57 -07:00
Gregory Schier
4c3a02ac53 Show decrypt error in secure input 2025-05-20 07:41:32 -07:00
Gregory Schier
1974d61aa4 Fix syntax highlighting 2025-05-19 15:41:19 -07:00
Gregory Schier
0bcb092854 Update README.md 2025-05-19 15:10:56 -07:00
Gregory Schier
409620f533 More advanced template grammar
Fixes https://feedback.yaak.app/p/cannot-escape-call-to-variable-in-json-body
2025-05-19 13:37:12 -07:00
Gregory Schier
3e9037f70a No longer mark environments as external in Git 2025-05-17 06:06:36 -07:00
15 changed files with 148 additions and 102 deletions

View File

@@ -5,6 +5,11 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
![366149288-f18e963f-0b68-4ecb-b8b8-cb71aa9aec02](https://github.com/user-attachments/assets/ca83b7ad-5708-411b-8faf-e36b365841a4)
## Contribution Policy
Yaak is open source, but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
## Feature Overview
- 🪂 Import data from Postman, Insomnia, OpenAPI, Swagger, or Curl.<br/>
@@ -14,6 +19,7 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
- ⛓️ Chain together multiple requests to dynamically reference values.<br/>
- 📂 Organize requests into workspaces and nested folders.<br/>
- 🧮 Use environment variables to easily switch between Prod and Dev.<br/>
- 🛡️ Secure arbitrary text values with end-to-end encryption<br/>
- 🏷️ Send dynamic values like UUIDs or timestamps using template tags.<br/>
- 🎨 Choose from many of the included themes, or make your own.<br/>
- 💽 Mirror workspace data to a directory for integration with Git or Dropbox.<br/>
@@ -21,17 +27,8 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
- 🔌 Create your own plugins for authentication, template tags, and more!<br/>
- 🛜 Configure a proxy to access firewall-blocked APIs
## Feedback and Bug Reports
## Useful Resources
All feedback, bug reports, questions, and feature requests should be reported to
[feedback.yaak.app](https://feedback.yaak.app).
## Community Projects
- [`yaak2postman`](https://github.com/BiteCraft/yaak2postman) CLI for converting Yaak data
exports to Postman-compatible collections
## Contribution Policy
Yaak is open source, but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
- [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://feedback.yaak.app/help)
- [Yaak vs Postman](https://yaak.app/blog/postman-alternative)

View File

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

View File

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

View File

@@ -16,3 +16,15 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.invoke_handler(generate_handler![check, activate, deactivate])
.build()
}
pub(crate) fn get_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}

View File

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

View File

@@ -70,8 +70,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
allEntries.push(entry);
if (entry.next == null && entry.prev == null) {
externalEntries.push(entry);
} else if (entry.next?.model === 'environment' || entry.prev?.model === 'environment') {
externalEntries.push(entry);
} else {
yaakEntries.push(entry);
}

View File

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

View File

@@ -12,7 +12,7 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onC
color?: Color | 'custom' | 'default';
variant?: 'border' | 'solid';
isLoading?: boolean;
size?: '2xs' | 'xs' | 'sm' | 'md';
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
forDropdown?: boolean;
@@ -114,7 +114,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{...props}
>
{isLoading ? (
<LoadingIcon size={size} className="mr-1" />
<LoadingIcon size={size === 'auto' ? 'md' : size} className="mr-1" />
) : leftSlot ? (
<div className="mr-2">{leftSlot}</div>
) : null}
@@ -128,7 +128,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{children}
</div>
{rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && <Icon icon="chevron_down" size={size} className="ml-1 -mr-1" />}
{forDropdown && (
<Icon icon="chevron_down" size={size === 'auto' ? 'md' : size} className="ml-1 -mr-1" />
)}
</button>
);
});

View File

@@ -1,7 +1,7 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
TagOpen: t.tagName,
TagClose: t.tagName,
TagOpen: t.bracket,
TagClose: t.bracket,
TagContent: t.keyword,
});

View File

@@ -80,6 +80,10 @@ function templateTags(
const inner = rawTag.replace(/^\$\{\[\s*/, '').replace(/\s*]}$/, '');
let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
if (inner.includes('\n')) {
return;
}
// The beta named the function `Response` but was changed in stable.
// Keep this here for a while because there's no easy way to migrate
if (name === 'Response') {

View File

@@ -11,6 +11,7 @@ import {
useRef,
useState,
} from 'react';
import { createFastMutation } from '../../hooks/useFastMutation';
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { copyToClipboard } from '../../lib/copy';
@@ -20,7 +21,10 @@ import {
convertTemplateToSecure,
} from '../../lib/encryption';
import { generateId } from '../../lib/generateId';
import { withEncryptionEnabled } from '../../lib/setupOrConfigureEncryption';
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
} from '../../lib/setupOrConfigureEncryption';
import { Button } from './Button';
import type { DropdownItem } from './Dropdown';
import { Dropdown } from './Dropdown';
@@ -29,6 +33,7 @@ import { Editor } from './Editor/Editor';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { IconTooltip } from './IconTooltip';
import { Label } from './Label';
import { HStack } from './Stacks';
@@ -67,7 +72,7 @@ export type InputProps = Pick<
placeholder?: string;
required?: boolean;
rightSlot?: ReactNode;
size?: 'xs' | 'sm' | 'md' | 'auto';
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
stateKey: EditorProps['stateKey'];
tint?: Color;
type?: 'text' | 'password';
@@ -231,6 +236,7 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
size === 'md' && 'min-h-md',
size === 'sm' && 'min-h-sm',
size === 'xs' && 'min-h-xs',
size === '2xs' && 'min-h-2xs',
)}
>
{tint != null && (
@@ -332,7 +338,10 @@ function EncryptionInput({
value: string | null;
security: ReturnType<typeof analyzeTemplate> | null;
obscured: boolean;
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true }, [ogForceUpdateKey]);
error: string | null;
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true, error: null }, [
ogForceUpdateKey,
]);
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
@@ -345,25 +354,48 @@ function EncryptionInput({
const security = analyzeTemplate(defaultValue ?? '');
if (analyzeTemplate(defaultValue ?? '') === 'global_secured') {
// Lazily update value to decrypted representation
convertTemplateToInsecure(defaultValue ?? '').then((value) => {
setState({ fieldType: 'encrypted', security, value, obscured: true });
templateToInsecure.mutate(defaultValue ?? '', {
onSuccess: (value) => {
setState({ fieldType: 'encrypted', security, value, obscured: true, error: null });
},
onError: (value) => {
setState({
fieldType: 'encrypted',
security,
value: null,
error: String(value),
obscured: true,
});
},
});
} else if (isEncryptionEnabled && !defaultValue) {
// Default to encrypted field for new encrypted inputs
setState({ fieldType: 'encrypted', security, value: '', obscured: true });
setState({ fieldType: 'encrypted', security, value: '', obscured: true, error: null });
} else if (isEncryptionEnabled) {
// Don't obscure plain text when encryption is enabled
setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: false });
setState({
fieldType: 'text',
security,
value: defaultValue ?? '',
obscured: false,
error: null,
});
} else {
// Don't obscure plain text when encryption is disabled
setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: true });
setState({
fieldType: 'text',
security,
value: defaultValue ?? '',
obscured: true,
error: null,
});
}
}, [defaultValue, isEncryptionEnabled, setState, state.value]);
const handleChange = useCallback(
(value: string, fieldType: PasswordFieldType) => {
if (fieldType === 'encrypted') {
convertTemplateToSecure(value).then((value) => onChange?.(value));
templateToSecure.mutate(value, { onSuccess: (value) => onChange?.(value) });
} else {
onChange?.(value);
}
@@ -372,7 +404,7 @@ function EncryptionInput({
const security = fieldType === 'encrypted' ? 'global_secured' : analyzeTemplate(value);
// Reset obscured value when the field type is being changed
const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== 'text';
return { fieldType, value, security, obscured };
return { fieldType, value, security, obscured, error: s.error };
});
},
[onChange, setState],
@@ -477,6 +509,23 @@ function EncryptionInput({
const type = state.obscured ? 'password' : 'text';
if (state.error) {
return (
<Button
variant="border"
color="danger"
size={props.size}
className="text-sm"
rightSlot={<IconTooltip content={state.error} icon="alert_triangle" />}
onClick={() => {
setupOrConfigureEncryption();
}}
>
{state.error.replace(/^Render Error: /i, '')}
</Button>
);
}
return (
<BaseInput
disableObscureToggle
@@ -488,8 +537,20 @@ function EncryptionInput({
tint={tint}
type={type}
rightSlot={rightSlot}
disabled={state.error != null}
className="pr-1.5" // To account for encryption dropdown
{...props}
/>
);
}
const templateToSecure = createFastMutation({
mutationKey: ['template-to-secure'],
mutationFn: convertTemplateToSecure,
});
const templateToInsecure = createFastMutation({
mutationKey: ['template-to-insecure'],
mutationFn: convertTemplateToInsecure,
disableToastError: true,
});

View File

@@ -116,6 +116,7 @@ export function PlainInput({
size === 'md' && 'min-h-md',
size === 'sm' && 'min-h-sm',
size === 'xs' && 'min-h-xs',
size === '2xs' && 'min-h-2xs',
)}
>
{tint != null && (

View File

@@ -1,9 +1,4 @@
import {
deleteModelById,
duplicateModelById,
getModel,
workspacesAtom,
} from '@yaakapp-internal/models';
import { duplicateModelById, getModel, workspacesAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import React, { useMemo } from 'react';
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
@@ -134,7 +129,9 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
hotKeyAction: 'sidebar.delete_selected_item',
hotKeyLabelOnly: true,
leftSlot: <Icon icon="trash" />,
onSelect: async () => deleteModelById(child.model, child.id),
onSelect: async () => {
await deleteModelWithConfirm(getModel(child.model, child.id));
},
},
];
}

View File

@@ -30,6 +30,7 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
try {
const data = await mutationFn(variables);
onSuccess?.(data);
onSettled?.();
return data;
} catch (err: unknown) {
const stringKey = mutationKey.join('.');
@@ -44,11 +45,9 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
});
}
onError?.(e);
} finally {
onSettled?.();
throw e;
}
return null;
};
const mutate = (

View File

@@ -11,7 +11,7 @@ export async function deleteModelWithConfirm(model: AnyModel | null): Promise<bo
}
const confirmed = await showConfirmDelete({
id: 'delete-model-' + model.model,
id: 'delete-model-' + model.id,
title: 'Delete ' + modelTypeLabel(model),
description: (
<>