Compare commits

..

32 Commits

Author SHA1 Message Date
Gregory Schier
1a1751c23e Fix window path issue 2025-10-02 08:25:00 -07:00
Gregory Schier
17de0678b0 Remove unused import from window.rs 2025-10-02 08:05:07 -07:00
Gregory Schier
20bb89de33 Try fix oauth window creation 2025-10-02 07:45:50 -07:00
Gregory Schier
8a634b1056 Add back environment.base (#260) 2025-10-02 06:04:27 -07:00
Gregory Schier
57f231ca00 Add trial status to links 2025-10-01 21:14:26 -07:00
Gregory Schier
cb1c0e4d8c Fix ref 2025-10-01 21:07:44 -07:00
Gregory Schier
2152cf87d7 Tweak license badge and fix keyring dep 2025-10-01 21:01:27 -07:00
Gregory Schier
8662b230e7 Oops, actually fix 2025-10-01 16:54:05 -07:00
Gregory Schier
3a8a6484c7 Fix release windows signing 2025-10-01 16:42:38 -07:00
Gregory Schier
f92594a16d Fix release tauri config 2025-10-01 10:51:26 -07:00
Gregory Schier
7969fcb76c Alias keyring function 2025-10-01 10:22:06 -07:00
Gregory Schier
eafefb1894 Fix setting 2025-10-01 09:44:18 -07:00
Gregory Schier
9a94a15c82 Integrated update experience (#259) 2025-10-01 09:36:36 -07:00
Gregory Schier
757d28c235 License and updater Cargo features (#258) 2025-09-29 22:08:05 -07:00
Gregory Schier
6c79c1ef3f Rework licensing flows to be more friendly 2025-09-29 15:40:15 -07:00
Gregory Schier
7262eccac5 Fix keyring errors 2025-09-29 10:53:20 -07:00
Gregory Schier
4989a5f759 Add back cmd palette icon 2025-09-29 09:39:15 -07:00
Gregory Schier
0b0b05d29c Add keyring template function 2025-09-29 08:56:24 -07:00
Gregory Schier
b3d6d87bee Delete duplicate folder environments on upsert 2025-09-29 07:48:07 -07:00
Gregory Schier
6abbdc8726 Filter out current variable from autocomplete and show sub environment variables in base environment autocomplete 2025-09-29 06:57:04 -07:00
Gregory Schier
b9613591f8 Update resource links in README.md 2025-09-28 15:38:14 -07:00
Gregory Schier
eb555989ac Force grpcurl to posix paths 2025-09-25 08:40:57 -07:00
Gregory Schier
b77f1375fd Fix test with timezone 2025-09-25 08:03:07 -07:00
Gregory Schier
3c438b3da7 Add cmdctrl+Backspace for request delete 2025-09-25 07:28:01 -07:00
Gregory Schier
df15543c80 Explicitly set the request layout (#257) 2025-09-25 07:23:52 -07:00
Gregory Schier
73ad86c6b9 Fix workspace settings scroll with long description 2025-09-25 07:22:42 -07:00
Gregory Schier
615de8b3cc Update importers for folder environment and fix tests 2025-09-25 07:12:50 -07:00
Gregory Schier
2418bd0672 Update README.md 2025-09-24 11:03:23 -07:00
Gregory Schier
b3414ee60f Fix ephemeral response body reading 2025-09-22 14:07:25 -07:00
Gregory Schier
8fe50959b9 Add migrate for base environment to sync logic 2025-09-22 11:15:32 -07:00
Gregory Schier
523e7dcf16 Add bootstrap to release script (to fix lint) 2025-09-22 08:57:05 -07:00
Gregory Schier
7951f3a7bd Tweak light theme, high contrast themes, and fix env null reference 2025-09-22 08:36:40 -07:00
135 changed files with 1784 additions and 656 deletions

View File

@@ -72,9 +72,16 @@ jobs:
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Some things (eg. WASM package) requires building before lint will work
- name: Run bootstrap
run: npm run bootstrap
- name: Run lint
run: npm run lint
- name: Run tests
run: npm test
- name: Set version
run: npm run replace-version
env:
@@ -106,5 +113,5 @@ jobs:
releaseName: 'Release __VERSION__'
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
prerelease: true
args: '${{ matrix.args }} --config ./src-tauri/tauri.commercial.conf.json'

View File

@@ -3,7 +3,7 @@
Yaak is a desktop API client for interacting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC
APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
![366149288-f18e963f-0b68-4ecb-b8b8-cb71aa9aec02](https://github.com/user-attachments/assets/ca83b7ad-5708-411b-8faf-e36b365841a4)
![Yaak API Client](https://yaak.app/static/screenshot.png)
## Contribution Policy
@@ -31,4 +31,7 @@ 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)
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)

16
package-lock.json generated
View File

@@ -38,6 +38,7 @@
"plugins/template-function-uuid",
"plugins/template-function-xml",
"plugins/themes-yaak",
"src-tauri",
"src-tauri/yaak-crypto",
"src-tauri/yaak-git",
"src-tauri/yaak-fonts",
@@ -812,6 +813,12 @@
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
@@ -4245,6 +4252,10 @@
"resolved": "src-tauri/yaak-sync",
"link": true
},
"node_modules/@yaakapp-internal/tauri": {
"resolved": "src-tauri",
"link": true
},
"node_modules/@yaakapp-internal/templates": {
"resolved": "src-tauri/yaak-templates",
"link": true
@@ -18949,6 +18960,7 @@
"name": "@yaak/template-function-timestamp",
"version": "0.1.0",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
}
},
@@ -18984,6 +18996,10 @@
"name": "@yaak/themes-yaak",
"version": "0.1.0"
},
"src-tauri": {
"name": "@yaakapp-internal/tauri",
"version": "1.0.0"
},
"src-tauri/yaak-crypto": {
"name": "@yaakapp-internal/crypto",
"version": "1.0.0"

View File

@@ -37,6 +37,7 @@
"plugins/template-function-uuid",
"plugins/template-function-xml",
"plugins/themes-yaak",
"src-tauri",
"src-tauri/yaak-crypto",
"src-tauri/yaak-git",
"src-tauri/yaak-fonts",
@@ -53,10 +54,11 @@
"scripts": {
"start": "npm run app-dev",
"app-build": "tauri build",
"app-dev": "tauri dev --no-watch --config ./src-tauri/tauri-dev.conf.json",
"app-dev": "tauri dev --no-watch --config ./src-tauri/tauri.development.conf.json",
"migration": "node scripts/create-migration.cjs",
"build": "npm run --workspaces --if-present build",
"build-plugins": "npm run --workspaces --if-present build",
"test": "npm run --workspaces --if-present test",
"icons": "run-p icons:*",
"icons:dev": "tauri icon src-tauri/icons/icon.png --output src-tauri/icons/release",
"icons:release": "tauri icon src-tauri/icons/icon-dev.png --output src-tauri/icons/dev",

View File

@@ -12,6 +12,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx"
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
}
}

View File

@@ -12,6 +12,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
}
}

View File

@@ -41,8 +41,8 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
if (protoDir) {
inferredIncludes.add(protoDir);
} else {
inferredIncludes.add(path.join(f, '..'));
inferredIncludes.add(path.join(f, '..', '..'));
inferredIncludes.add(path.posix.join(f, '..'));
inferredIncludes.add(path.posix.join(f, '..', '..'));
}
}

View File

@@ -12,6 +12,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -12,6 +12,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -12,6 +12,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
}
}

View File

@@ -12,7 +12,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonwebtoken": "^9.0.2"

View File

@@ -12,6 +12,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -88,9 +88,9 @@ export async function getAuthorizationCode(
const code = await new Promise<string>(async (resolve, reject) => {
let foundCode = false;
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
label: 'oauth-authorization-url',
dataDirKey,
async onClose() {
if (!foundCode) {
reject(new Error('Authorization window closed'));

View File

@@ -1,6 +1,6 @@
import type { Context } from '@yaakapp/api';
import type { AccessToken, AccessTokenRawResponse } from '../store';
import { getToken, storeToken } from '../store';
import type { AccessToken, AccessTokenRawResponse} from '../store';
import { getDataDirKey , getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
export async function getImplicit(
@@ -60,7 +60,9 @@ export async function getImplicit(
const newToken = await new Promise<AccessToken>(async (resolve, reject) => {
let foundAccessToken = false;
const authorizationUrlStr = authorizationUrl.toString();
const dataDirKey = await getDataDirKey(ctx, contextId);
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
label: 'oauth-authorization-url',
async onClose() {

View File

@@ -12,7 +12,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonpath-plus": "^10.3.0"

View File

@@ -7,7 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.8",

View File

@@ -7,7 +7,8 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
},
"dependencies": {
"shell-quote": "^1.8.1"

View File

@@ -7,7 +7,8 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
},
"dependencies": {
"yaml": "^2.4.2"

View File

@@ -30,18 +30,25 @@ export function convertInsomniaV5(parsed: any) {
model: 'workspace',
name: parsed.name,
description: meta.description || undefined,
...importHeaders(parsed),
...importAuthentication(parsed),
});
// Import environments
resources.environments.push(
importEnvironment(parsed.environments, meta.id, true),
...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)),
);
// Import folders
const nextFolder = (children: any[], parentId: string) => {
for (const child of children ?? []) {
if (!isJSObject(child)) continue;
if (Array.isArray(child.children)) {
resources.folders.push(importFolder(child, meta.id, parentId));
const { folder, environment } = importFolder(child, meta.id, parentId);
resources.folders.push(folder);
if (environment) resources.environments.push(environment);
nextFolder(child.children, child.meta.id);
} else if (child.method) {
resources.httpRequests.push(importHttpRequest(child, meta.id, parentId));
@@ -191,8 +198,8 @@ function importWebsocketRequest(
};
}
function importHeaders(r: any) {
const headers = (r.headers ?? [])
function importHeaders(obj: any) {
const headers = (obj.headers ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
@@ -202,19 +209,19 @@ function importHeaders(r: any) {
return { headers } as const;
}
function importAuthentication(r: any) {
function importAuthentication(obj: any) {
let authenticationType: string | null = null;
let authentication = {};
if (r.authentication?.type === 'bearer') {
if (obj.authentication?.type === 'bearer') {
authenticationType = 'bearer';
authentication = {
token: convertSyntax(r.authentication.token),
token: convertSyntax(obj.authentication.token),
};
} else if (r.authentication?.type === 'basic') {
} else if (obj.authentication?.type === 'basic') {
authenticationType = 'basic';
authentication = {
username: convertSyntax(r.authentication.username),
password: convertSyntax(r.authentication.password),
username: convertSyntax(obj.authentication.username),
password: convertSyntax(obj.authentication.password),
};
}
@@ -225,22 +232,50 @@ function importFolder(
f: any,
workspaceId: string,
parentId: string,
): PartialImportResources['folders'][0] {
): {
folder: PartialImportResources['folders'][0];
environment: PartialImportResources['environments'][0] | null;
} {
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;
let environment: PartialImportResources['environments'][0] | null = null;
if (Object.keys(f.environment ?? {}).length > 0) {
environment = {
id: convertId(id + 'folder'),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
workspaceId: convertId(workspaceId),
public: true,
parentModel: 'folder',
parentId: convertId(id),
model: 'environment',
name: 'Folder Environment',
variables: Object.entries(f.environment ?? {}).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`,
})),
};
}
return {
model: 'folder',
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
workspaceId: convertId(workspaceId),
description: f.description || undefined,
name: f.name,
folder: {
model: 'folder',
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
workspaceId: convertId(workspaceId),
description: f.description || undefined,
name: f.name,
...importAuthentication(f),
...importHeaders(f),
},
environment,
};
}
@@ -263,7 +298,8 @@ function importEnvironment(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
sortPriority: sortKey, // Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
parentModel: isParent ? 'workspace' : 'environment',
parentId: null,
model: 'environment',
name: e.name,
variables: Object.entries(e.data ?? {}).map(([name, value]) => ({

View File

@@ -38,6 +38,8 @@ collection:
name: foo
value: bar
disabled: false
environment:
folder_env_var: testing
- name: New Request
meta:
id: req_e3f8cdbd58784a539dd4c1e127d73451

View File

@@ -2,7 +2,6 @@
"resources": {
"environments": [
{
"base": true,
"createdAt": "2025-05-14T04:45:24.903",
"id": "GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce",
"model": "environment",
@@ -10,6 +9,26 @@
"public": true,
"updatedAt": "2025-05-14T04:45:24.903",
"variables": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c",
"parentId": null,
"parentModel": "workspace"
},
{
"createdAt": "2025-05-16T16:48:12.298",
"id": "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7folder",
"model": "environment",
"name": "Folder Environment",
"parentId": "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7",
"parentModel": "folder",
"public": true,
"updatedAt": "2025-05-16T16:49:02.427",
"variables": [
{
"enabled": true,
"name": "folder_env_var",
"value": "testing"
}
],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
],
@@ -22,7 +41,16 @@
"name": "My Folder",
"sortPriority": -1747414092298,
"updatedAt": "2025-05-16T16:49:02.427",
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c",
"authentication": {},
"authenticationType": null,
"headers": [
{
"enabled": true,
"name": "foo",
"value": "bar"
}
]
}
],
"grpcRequests": [],
@@ -80,7 +108,10 @@
"id": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c",
"model": "workspace",
"name": "Debugging",
"updatedAt": "2025-05-14T04:45:24.902"
"updatedAt": "2025-05-14T04:45:24.902",
"authentication": {},
"authenticationType": null,
"headers": []
}
]
}

View File

@@ -4,11 +4,12 @@
{
"createdAt": "2025-01-13T15:15:43.767",
"updatedAt": "2025-01-13T15:15:55.209",
"base": true,
"public": true,
"id": "GENERATE_ID::env_20945044d3c8497ca8b717bef750987e",
"model": "environment",
"name": "Base Environment",
"parentId": null,
"parentModel": "workspace",
"variables": [
{
"enabled": true,
@@ -21,11 +22,12 @@
{
"createdAt": "2025-01-13T15:15:58.515",
"updatedAt": "2025-01-13T15:16:34.705",
"base": false,
"public": true,
"id": "GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2",
"model": "environment",
"name": "Production",
"parentId": null,
"parentModel": "environment",
"sortPriority": 1736781358515,
"variables": [
{
@@ -39,8 +41,9 @@
{
"createdAt": "2025-01-13T15:16:14.707",
"updatedAt": "2025-01-13T15:16:31.078",
"base": false,
"public": true,
"parentId": null,
"parentModel": "environment",
"id": "GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243",
"model": "environment",
"name": "Staging",
@@ -64,7 +67,10 @@
"model": "folder",
"name": "Top Level",
"sortPriority": -1736781404718,
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53",
"authentication": {},
"authenticationType": null,
"headers": []
}
],
"grpcRequests": [
@@ -165,7 +171,10 @@
"description": "This is the description",
"id": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53",
"model": "workspace",
"name": "Dummy"
"name": "Dummy",
"authentication": {},
"authenticationType": null,
"headers": []
}
]
}

View File

@@ -7,7 +7,8 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
},
"dependencies": {
"openapi-to-postmanv2": "^5.0.0",

View File

@@ -8,6 +8,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
}
}

View File

@@ -57,9 +57,11 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
const rawDescription = info.description;
const description =
typeof rawDescription === 'object' && rawDescription !== null && 'content' in rawDescription
typeof rawDescription === 'object' && rawDescription != null && 'content' in rawDescription
? String(rawDescription.content)
: String(rawDescription);
: rawDescription == null
? undefined
: String(rawDescription);
const workspace: ExportResources['workspaces'][0] = {
model: 'workspace',
@@ -75,6 +77,8 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
id: generateId('environment'),
name: 'Global Variables',
workspaceId: workspace.id,
parentModel: 'workspace',
parentId: null,
variables:
toArray<{ key: string; value: string }>(root.variable).map((v) => ({
name: v.key,

View File

@@ -13,7 +13,9 @@
"model": "environment",
"name": "Global Variables",
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0"
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"parentId": null,
"parentModel": "workspace"
}
],
"httpRequests": [
@@ -25,6 +27,7 @@
"name": "Request 1",
"method": "GET",
"url": "",
"sortPriority": 2,
"urlParameters": [],
"body": {},
"bodyType": null,
@@ -39,6 +42,7 @@
"folderId": "GENERATE_ID::FOLDER_0",
"name": "Request 2",
"method": "GET",
"sortPriority": 3,
"url": "",
"urlParameters": [],
"body": {},
@@ -52,6 +56,7 @@
"id": "GENERATE_ID::HTTP_REQUEST_2",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"sortPriority": 4,
"name": "Request 3",
"method": "GET",
"url": "",
@@ -69,6 +74,7 @@
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"id": "GENERATE_ID::FOLDER_0",
"name": "Top Folder",
"sortPriority": 0,
"folderId": null
},
{
@@ -76,6 +82,7 @@
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"id": "GENERATE_ID::FOLDER_1",
"name": "Nested Folder",
"sortPriority": 1,
"folderId": "GENERATE_ID::FOLDER_0"
}
]

View File

@@ -13,6 +13,8 @@
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"model": "environment",
"name": "Global Variables",
"parentId": null,
"parentModel": "workspace",
"variables": [
{
"name": "COLLECTION VARIABLE",
@@ -28,6 +30,7 @@
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"folderId": null,
"name": "Form URL",
"sortPriority": 0,
"method": "POST",
"url": "example.com/:foo/:bar",
"urlParameters": [

View File

@@ -7,6 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
}
}

View File

@@ -66,7 +66,20 @@ export function migrateImport(contents: string) {
}
}
return { resources: parsed.resources }; // Should already be in the correct format
// Migrate v4 to v5
for (const environment of parsed.resources.environments ?? []) {
if ('base' in environment && environment.base) {
environment.parentModel = 'workspace';
environment.parentId = null;
delete environment.base;
} else if ('base' in environment && !environment.base) {
environment.parentModel = 'environment';
environment.parentId = null;
delete environment.base;
}
}
return { resources: parsed.resources };
}
function isJSObject(obj: unknown) {

View File

@@ -31,16 +31,20 @@ describe('importer-yaak', () => {
JSON.stringify({
yaakSchema: 2,
resources: {
environments: [{
id: 'e_1',
workspaceId: 'w_1',
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
}],
workspaces: [{
id: 'w_1',
variables: [{ name: 'W1', value: 'W1!' }],
}],
environments: [
{
id: 'e_1',
workspaceId: 'w_1',
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
},
],
workspaces: [
{
id: 'w_1',
variables: [{ name: 'W1', value: 'W1!' }],
},
],
},
}),
);
@@ -48,21 +52,98 @@ describe('importer-yaak', () => {
expect(imported).toEqual(
expect.objectContaining({
resources: {
workspaces: [{
id: 'w_1',
}],
environments: [{
id: 'e_1',
base: false,
workspaceId: 'w_1',
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
}, {
id: 'GENERATE_ID::base_env_w_1',
workspaceId: 'w_1',
name: 'Global Variables',
variables: [{ name: 'W1', value: 'W1!' }],
}],
workspaces: [
{
id: 'w_1',
},
],
environments: [
{
id: 'e_1',
workspaceId: 'w_1',
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
parentModel: 'environment',
parentId: null,
},
{
id: 'GENERATE_ID::base_env_w_1',
workspaceId: 'w_1',
name: 'Global Variables',
variables: [{ name: 'W1', value: 'W1!' }],
},
],
},
}),
);
});
test('converts schema 4 to 5', () => {
const imported = migrateImport(
JSON.stringify({
yaakSchema: 2,
resources: {
environments: [
{
id: 'e_1',
workspaceId: 'w_1',
base: false,
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
},
{
id: 'e_1',
workspaceId: 'w_1',
base: true,
name: 'Global Variables',
variables: [{ name: 'G1', value: 'G1!' }],
},
],
folders: [
{
id: 'f_1',
},
],
workspaces: [
{
id: 'w_1',
},
],
},
}),
);
expect(imported).toEqual(
expect.objectContaining({
resources: {
workspaces: [
{
id: 'w_1',
},
],
folders: [
{
id: 'f_1',
},
],
environments: [
{
id: 'e_1',
workspaceId: 'w_1',
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
parentModel: 'environment',
parentId: null,
},
{
id: 'e_1',
workspaceId: 'w_1',
name: 'Global Variables',
parentModel: 'workspace',
parentId: null,
variables: [{ name: 'G1', value: 'G1!' }],
},
],
},
}),
);

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -7,7 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonpath-plus": "^10.3.0"

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -7,6 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
}
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -7,7 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonpath-plus": "^10.3.0",

View File

@@ -5,9 +5,11 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx",
"test": "vitest --run tests"
},
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
}
}

View File

@@ -1,6 +1,7 @@
import type { TemplateFunctionArg } from '@yaakapp-internal/plugins';
import type { PluginDefinition } from '@yaakapp/api';
import type { ContextFn } from 'date-fns';
import {
addDays,
addHours,
@@ -24,7 +25,8 @@ const dateArg: TemplateFunctionArg = {
name: 'date',
label: 'Timestamp',
optional: true,
description: 'Can be a timestamp in milliseconds, ISO string, or anything parseable by JS `new Date()`',
description:
'Can be a timestamp in milliseconds, ISO string, or anything parseable by JS `new Date()`',
placeholder: new Date().toISOString(),
};
@@ -148,8 +150,12 @@ export function calculateDatetime(args: { date?: string; expression?: string }):
return jsDate.toISOString();
}
export function formatDatetime(args: { date?: string; format?: string }): string {
export function formatDatetime(args: {
date?: string;
format?: string;
in?: ContextFn<Date>;
}): string {
const { date, format = 'yyyy-MM-dd HH:mm:ss' } = args;
const d = parseDateString(date ?? '');
return formatDate(d, String(format));
return formatDate(d, String(format), { in: args.in });
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { calculateDatetime, formatDatetime } from '../src';
import { tz } from "@date-fns/tz";
describe('formatDatetime', () => {
it('returns formatted current date', () => {
@@ -13,12 +14,12 @@ describe('formatDatetime', () => {
});
it('returns formatted specific timestamp', () => {
const result = formatDatetime({ date: '1752435296000' });
const result = formatDatetime({ date: '1752435296000', in: tz('America/Vancouver') });
expect(result).toBe('2025-07-13 12:34:56');
});
it('returns formatted specific timestamp with decimals', () => {
const result = formatDatetime({ date: '1752435296000.19' });
const result = formatDatetime({ date: '1752435296000.19', in: tz('America/Vancouver') });
expect(result).toBe('2025-07-13 12:34:56');
});

View File

@@ -7,7 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"uuid": "^11.1.0"

View File

@@ -7,7 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.8",

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"eslint . --ext .ts,.tsx"
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -2,6 +2,49 @@ import type { PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
themes: [
{
id: 'high-contrast',
label: 'High Contrast Light',
dark: false,
base: {
surface: 'white',
surfaceHighlight: 'hsl(218,24%,93%)',
text: 'black',
textSubtle: 'hsl(217,24%,40%)',
textSubtlest: 'hsl(217,24%,40%)',
border: 'hsl(217,22%,50%)',
borderSubtle: 'hsl(217,22%,60%)',
primary: 'hsl(267,67%,47%)',
secondary: 'hsl(218,18%,53%)',
info: 'hsl(206,100%,36%)',
success: 'hsl(155,100%,26%)',
notice: 'hsl(45,100%,31%)',
warning: 'hsl(30,99%,34%)',
danger: 'hsl(334,100%,35%)',
},
},
{
id: 'high-contrast-dark',
label: 'High Contrast Dark',
dark: true,
base: {
surface: 'hsl(0,0%,0%)',
surfaceHighlight: 'hsl(0,0%,20%)',
text: 'hsl(0,0%,100%)',
textSubtle: 'hsl(0,0%,90%)',
textSubtlest: 'hsl(0,0%,80%)',
selection: 'hsl(276,100%,30%)',
surfaceActive: 'hsl(276,100%,30%)',
border: 'hsl(0,0%,60%)',
primary: 'hsl(266,100%,85%)',
secondary: 'hsl(242,20%,72%)',
info: 'hsl(208,100%,83%)',
success: 'hsl(150,100%,63%)',
notice: 'hsl(49,100%,77%)',
warning: 'hsl(28,100%,73%)',
danger: 'hsl(343,100%,79%)',
},
},
{
id: 'catppuccin-frappe',
label: 'Catppuccin Frappé',

159
src-tauri/Cargo.lock generated
View File

@@ -503,15 +503,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -739,15 +730,6 @@ dependencies = [
"toml 0.8.23",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.26"
@@ -1220,31 +1202,23 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "dbus"
version = "0.9.7"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b"
checksum = "190b6255e8ab55a7b568df5a883e9497edc3e4821c06396612048b430e5ad1e9"
dependencies = [
"libc",
"libdbus-sys",
"winapi",
"windows-sys 0.59.0",
]
[[package]]
name = "dbus-secret-service"
version = "4.0.3"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"dbus",
"futures-util",
"hkdf",
"num",
"once_cell",
"rand 0.8.5",
"sha2",
"zeroize",
]
[[package]]
@@ -2273,15 +2247,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -2695,7 +2660,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -2855,15 +2819,17 @@ dependencies = [
[[package]]
name = "keyring"
version = "4.0.0-rc.1"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb06f73ca0ea1cbd3858e54404585e33dccb860cb4fc8a66ad5e75a5736f3f19"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"security-framework 2.11.1",
"security-framework 3.2.0",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
@@ -2936,9 +2902,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libdbus-sys"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72"
checksum = "5cbe856efeb50e4681f010e9aaa2bf0a644e10139e54cde10fc83a307c23bd9f"
dependencies = [
"pkg-config",
]
@@ -3359,76 +3325,12 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -5163,10 +5065,11 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.219"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd"
dependencies = [
"serde_core",
"serde_derive",
]
@@ -5203,10 +5106,19 @@ dependencies = [
]
[[package]]
name = "serde_derive"
version = "1.0.219"
name = "serde_core"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33"
dependencies = [
"proc-macro2",
"quote",
@@ -5236,6 +5148,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -7918,7 +7841,6 @@ dependencies = [
"charset",
"chrono",
"cookie",
"encoding_rs",
"eventsource-client",
"http",
"log",
@@ -7946,6 +7868,7 @@ dependencies = [
"thiserror 2.0.12",
"tokio",
"tokio-stream",
"ts-rs",
"uuid",
"yaak-common",
"yaak-crypto",
@@ -8121,6 +8044,7 @@ dependencies = [
"dunce",
"futures-util",
"hex",
"keyring",
"log",
"md5 0.7.0",
"path-slash",
@@ -8162,6 +8086,7 @@ dependencies = [
"notify",
"serde",
"serde_json",
"serde_path_to_error",
"serde_yaml",
"sha1",
"tauri",

View File

@@ -32,6 +32,9 @@ strip = true # Automatically strip symbols from the binary.
[features]
cargo-clippy = []
default = []
updater = []
license = ["yaak-license"]
[build-dependencies]
tauri-build = { version = "2.4.1", features = [] }
@@ -40,9 +43,9 @@ tauri-build = { version = "2.4.1", features = [] }
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[dependencies]
charset = "0.1.5"
chrono = { workspace = true, features = ["serde"] }
cookie = "0.18.1"
encoding_rs = "0.8.35"
eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.14.0" }
http = { version = "1.2.0", default-features = false }
log = "0.4.27"
@@ -55,19 +58,20 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2.3.0"
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = { version = "2.7.0", features = ["colored"] }
tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.1"
tauri-plugin-shell = { workspace = true }
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-single-instance = { version = "2.3.4", features = ["deep-link"] }
tauri-plugin-updater = "2.9.0"
tauri-plugin-window-state = "2.4.0"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-stream = "0.1.17"
ts-rs = { workspace = true }
uuid = "1.12.1"
yaak-common = { workspace = true }
yaak-crypto = { workspace = true }
@@ -75,7 +79,7 @@ yaak-fonts = { workspace = true }
yaak-git = { path = "yaak-git" }
yaak-grpc = { path = "yaak-grpc" }
yaak-http = { workspace = true }
yaak-license = { path = "yaak-license" }
yaak-license = { path = "yaak-license", optional = true }
yaak-mac-window = { path = "yaak-mac-window" }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
@@ -83,25 +87,25 @@ yaak-sse = { workspace = true }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-ws = { path = "yaak-ws" }
charset = "0.1.5"
[workspace.dependencies]
chrono = "0.4.41"
hex = "0.4.3"
keyring = "3.6.3"
reqwest = "0.12.20"
reqwest_cookie_store = "0.8.0"
rustls = { version = "0.23.27", default-features = false }
rustls-platform-verifier = "0.6.0"
serde = "1.0.219"
serde_json = "1.0.140"
sha2 = "0.10.9"
tauri = "2.8.5"
tauri-plugin = "2.4.0"
tauri-plugin-dialog = "2.4.0"
tauri-plugin-shell = "2.3.1"
tokio = "1.45.1"
thiserror = "2.0.12"
tokio = "1.45.1"
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"
yaak-common = { path = "yaak-common" }
yaak-crypto = { path = "yaak-crypto" }
yaak-fonts = { path = "yaak-fonts" }

View File

@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };
export type UpdateResponseAction = "install" | "skip";
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
export type YaakNotificationAction = { label: string, url: string, };

View File

@@ -1,8 +1,6 @@
{
"$schema": "../gen/schemas/capabilities.json",
"identifier": "main",
"description": "Main permissions",
"local": true,
"identifier": "default",
"description": "Default capabilities for all build variants",
"windows": [
"*"
],
@@ -11,6 +9,7 @@
"core:event:allow-emit",
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:path:allow-resolve-directory",
"os:allow-os-type",
"clipboard-manager:allow-clear",
"clipboard-manager:allow-write-text",
@@ -54,7 +53,6 @@
"yaak-crypto:default",
"yaak-fonts:default",
"yaak-git:default",
"yaak-license:default",
"yaak-mac-window:default",
"yaak-models:default",
"yaak-plugins:default",

6
src-tauri/package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/tauri",
"private": true,
"version": "1.0.0",
"main": "bindings/index.ts"
}

View File

@@ -19,9 +19,13 @@ pub enum Error {
#[error(transparent)]
GitError(#[from] yaak_git::error::Error),
#[error(transparent)]
TokioTimeoutElapsed(#[from] tokio::time::error::Elapsed),
#[error(transparent)]
WebsocketError(#[from] yaak_ws::error::Error),
#[cfg(feature = "license")]
#[error(transparent)]
LicenseError(#[from] yaak_license::error::Error),

View File

@@ -39,6 +39,19 @@ pub(crate) async fn import_data<R: Runtime>(
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
("folder", Some(parent_id)) => {
v.parent_id = Some(maybe_gen_id::<Folder>(&parent_id, &mut id_map));
}
("", _) => {
// Fix any empty ones
v.parent_model = "workspace".to_string();
}
_ => {
// Parent ID only required for the folder case
v.parent_id = None;
}
};
v
})
.collect();

View File

@@ -26,6 +26,7 @@ use tauri_plugin_log::{Builder, Target, TargetKind};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak_common::window::WorkspaceWindowTrait;
use yaak_grpc::manager::{DynamicMessage, GrpcHandle};
use yaak_grpc::{Code, ServiceDefinition, deserialize_message, serialize_message};
@@ -73,6 +74,8 @@ struct AppMetaData {
name: String,
app_data_dir: String,
app_log_dir: String,
feature_updater: bool,
feature_license: bool,
}
#[tauri::command]
@@ -85,6 +88,8 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
name: app_handle.package_info().name.to_string(),
app_data_dir: app_data_dir.to_string_lossy().to_string(),
app_log_dir: app_log_dir.to_string_lossy().to_string(),
feature_license: cfg!(feature = "license"),
feature_updater: cfg!(feature = "updater"),
})
}
@@ -683,6 +688,12 @@ async fn cmd_grpc_go<R: Runtime>(
Ok(conn.id)
}
#[tauri::command]
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
app_handle.request_restart();
Ok(())
}
#[tauri::command]
async fn cmd_send_ephemeral_request<R: Runtime>(
mut request: HttpRequest,
@@ -720,13 +731,10 @@ async fn cmd_format_json(text: &str) -> YaakResult<String> {
#[tauri::command]
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>,
response_id: &str,
response: HttpResponse,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
let response = app_handle.db().get_http_response(response_id)?;
let body_path = match response.body_path {
None => {
return Err(GenericError("Response body path not set".to_string()));
@@ -836,7 +844,7 @@ async fn cmd_get_http_authentication_config<R: Runtime>(
AnyModel::Workspace(m) => (m.id, None),
m => {
return Err(GenericError(format!("Unsupported model to call auth config {m:?}")));
},
}
};
let environment_chain =
@@ -1190,13 +1198,13 @@ async fn cmd_new_child_window(
title: &str,
inner_size: (f64, f64),
) -> YaakResult<()> {
window::create_child_window(&parent_window, url, label, title, inner_size);
window::create_child_window(&parent_window, url, label, title, inner_size)?;
Ok(())
}
#[tauri::command]
async fn cmd_new_main_window(app_handle: AppHandle, url: &str) -> YaakResult<()> {
window::create_main_window(&app_handle, url);
window::create_main_window(&app_handle, url)?;
Ok(())
}
@@ -1206,7 +1214,12 @@ async fn cmd_check_for_updates<R: Runtime>(
yaak_updater: State<'_, Mutex<YaakUpdater>>,
) -> YaakResult<bool> {
let update_mode = get_update_mode(&window).await?;
Ok(yaak_updater.lock().await.check_now(&window, update_mode, UpdateTrigger::User).await?)
let settings = window.db().get_settings();
Ok(yaak_updater
.lock()
.await
.check_now(&window, update_mode, settings.auto_download_updates, UpdateTrigger::User)
.await?)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -1257,7 +1270,6 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_fs::init())
.plugin(yaak_license::init())
.plugin(yaak_mac_window::init())
.plugin(yaak_models::init())
.plugin(yaak_plugins::init())
@@ -1267,6 +1279,11 @@ pub fn run() {
.plugin(yaak_ws::init())
.plugin(yaak_sync::init());
#[cfg(feature = "license")]
{
builder = builder.plugin(yaak_license::init());
}
builder
.setup(|app| {
{
@@ -1287,6 +1304,7 @@ pub fn run() {
),
color: Some(Color::Danger),
icon: None,
timeout: None,
},
);
};
@@ -1344,6 +1362,7 @@ pub fn run() {
cmd_plugin_info,
cmd_reload_plugins,
cmd_render_template,
cmd_restart,
cmd_save_response,
cmd_send_ephemeral_request,
cmd_send_http_request,
@@ -1383,19 +1402,27 @@ pub fn run() {
label,
..
} => {
let w = app_handle.get_webview_window(&label).unwrap();
let h = app_handle.clone();
// Run update check whenever the window is focused
tauri::async_runtime::spawn(async move {
if w.db().get_settings().autoupdate {
let val: State<'_, Mutex<YaakUpdater>> = h.state();
let update_mode = get_update_mode(&w).await.unwrap();
if let Err(e) = val.lock().await.maybe_check(&w, update_mode).await {
warn!("Failed to check for updates {e:?}");
if cfg!(feature = "updater") {
// Run update check whenever the window is focused
let w = app_handle.get_webview_window(&label).unwrap();
let h = app_handle.clone();
tauri::async_runtime::spawn(async move {
let settings = w.db().get_settings();
if settings.autoupdate {
time::sleep(Duration::from_secs(3)).await; // Wait a bit so it's not so jarring
let val: State<'_, Mutex<YaakUpdater>> = h.state();
let update_mode = get_update_mode(&w).await.unwrap();
if let Err(e) = val
.lock()
.await
.maybe_check(&w, settings.auto_download_updates, update_mode)
.await
{
warn!("Failed to check for updates {e:?}");
}
};
};
});
});
}
let h = app_handle.clone();
tauri::async_runtime::spawn(async move {
@@ -1465,7 +1492,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
}
async fn call_frontend<R: Runtime>(
window: WebviewWindow<R>,
window: &WebviewWindow<R>,
event: &InternalEvent,
) -> Option<InternalEventPayload> {
window.emit_to(window.label(), "plugin_event", event.clone()).unwrap();

View File

@@ -7,9 +7,9 @@ use log::debug;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_common::api_client::yaak_api_client;
use yaak_common::platform::get_os;
use yaak_license::{LicenseCheckStatus, check_license};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
@@ -24,18 +24,22 @@ pub struct YaakNotifier {
last_check: SystemTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub struct YaakNotification {
timestamp: DateTime<Utc>,
timeout: Option<f64>,
id: String,
title: Option<String>,
message: String,
color: Option<String>,
action: Option<YaakNotificationAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub struct YaakNotificationAction {
label: String,
url: String,
@@ -73,12 +77,20 @@ 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(),
#[cfg(feature = "license")]
let license_check = {
use yaak_license::{LicenseCheckStatus, check_license};
match check_license(window).await {
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(),
Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(),
Ok(LicenseCheckStatus::InvalidLicense) => "invalid_license".to_string(),
Ok(LicenseCheckStatus::Trialing { .. }) => "trialing".to_string(),
Err(_) => "unknown".to_string(),
}
};
#[cfg(not(feature = "license"))]
let license_check = "disabled".to_string();
let settings = window.db().get_settings();
let num_launches = get_num_launches(app_handle).await;
let info = app_handle.package_info().clone();

View File

@@ -7,16 +7,16 @@ use crate::{
};
use chrono::Utc;
use cookie::Cookie;
use log::warn;
use log::{error, warn};
use tauri::{AppHandle, Emitter, Manager, Runtime, State};
use tauri_plugin_clipboard_manager::ClipboardExt;
use yaak_models::models::{HttpResponse, Plugin};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
Color, DeleteKeyValueResponse, EmptyPayload, FindHttpResponsesResponse, GetCookieValueResponse,
GetHttpRequestByIdResponse, GetKeyValueResponse, Icon, InternalEvent, InternalEventPayload,
ListCookieNamesResponse, PluginWindowContext, RenderGrpcRequestResponse,
Color, DeleteKeyValueResponse, EmptyPayload, ErrorResponse, FindHttpResponsesResponse,
GetCookieValueResponse, GetHttpRequestByIdResponse, GetKeyValueResponse, Icon, InternalEvent,
InternalEventPayload, ListCookieNamesResponse, PluginWindowContext, RenderGrpcRequestResponse,
RenderHttpRequestResponse, SendHttpRequestResponse, SetKeyValueResponse, ShowToastRequest,
TemplateRenderResponse, WindowNavigateEvent,
};
@@ -51,7 +51,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
InternalEventPayload::PromptTextRequest(_) => {
let window = get_window_from_window_context(app_handle, &window_context)
.expect("Failed to find window for render");
call_frontend(window, event).await
call_frontend(&window, event).await
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle
@@ -124,6 +124,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data }))
}
InternalEventPayload::ErrorResponse(resp) => {
error!("Plugin error: {}: {:?}", resp.error, resp);
let toast_event = plugin_handle.build_event_to_send(
&window_context,
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
@@ -133,6 +134,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
resp.error
),
color: Some(Color::Danger),
timeout: None,
..Default::default()
}),
None,
@@ -218,20 +220,29 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
}))
}
InternalEventPayload::OpenWindowRequest(req) => {
let label = req.label;
let (navigation_tx, mut navigation_rx) = tokio::sync::mpsc::channel(128);
let (close_tx, mut close_rx) = tokio::sync::mpsc::channel(128);
let win_config = CreateWindowConfig {
url: &req.url,
label: &label.clone(),
title: &req.title.unwrap_or_default(),
label: &req.label,
title: &req.title.clone().unwrap_or_default(),
navigation_tx: Some(navigation_tx),
close_tx: Some(close_tx),
inner_size: req.size.map(|s| (s.width, s.height)),
data_dir_key: req.data_dir_key,
inner_size: req.size.clone().map(|s| (s.width, s.height)),
data_dir_key: req.data_dir_key.clone(),
..Default::default()
};
create_window(app_handle, win_config);
if let Err(e) = create_window(app_handle, win_config) {
let error_event = plugin_handle.build_event_to_send(
&window_context,
&InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to create window: {:?}", e),
}),
None,
);
Box::pin(handle_plugin_event(app_handle, &error_event, plugin_handle)).await;
return;
}
{
let event_id = event.id.clone();

View File

@@ -1,15 +1,21 @@
use std::fmt::{Display, Formatter};
use std::time::SystemTime;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use crate::error::Result;
use log::info;
use tauri::{Manager, Runtime, WebviewWindow};
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
use tauri_plugin_updater::UpdaterExt;
use tauri_plugin_updater::{Update, UpdaterExt};
use tokio::task::block_in_place;
use tokio::time::sleep;
use ts_rs::TS;
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::generate_id;
use yaak_plugins::manager::PluginManager;
use crate::error::Error::GenericError;
use crate::is_dev;
const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12;
@@ -48,6 +54,7 @@ impl UpdateMode {
}
}
#[derive(PartialEq)]
pub enum UpdateTrigger {
Background,
User,
@@ -64,6 +71,7 @@ impl YaakUpdater {
&mut self,
window: &WebviewWindow<R>,
mode: UpdateMode,
auto_download: bool,
update_trigger: UpdateTrigger,
) -> Result<bool> {
// Only AppImage supports updates on Linux, so skip if it's not
@@ -78,7 +86,7 @@ impl YaakUpdater {
let update_key = format!("{:x}", md5::compute(settings.id));
self.last_update_check = SystemTime::now();
info!("Checking for updates mode={}", mode);
info!("Checking for updates mode={} autodl={}", mode, auto_download);
let w = window.clone();
let update_check_result = w
@@ -113,42 +121,44 @@ impl YaakUpdater {
None => false,
Some(update) => {
let w = window.clone();
w.dialog()
.message(format!(
"{} is available. Would you like to download and install it now?",
update.version
))
.buttons(MessageDialogButtons::OkCancelCustom(
"Download".to_string(),
"Later".to_string(),
))
.title("Update Available")
.show(|confirmed| {
if !confirmed {
return;
tauri::async_runtime::spawn(async move {
// Force native updater if specified (useful if a release broke the UI)
let native_install_mode =
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
== Some("native");
if native_install_mode {
start_native_update(&w, &update).await;
return;
}
// If it's a background update, try downloading it first
if update_trigger == UpdateTrigger::Background && auto_download {
info!("Downloading update {} in background", update.version);
if let Err(e) = download_update_idempotent(&w, &update).await {
error!("Failed to download {}: {}", update.version, e);
}
tauri::async_runtime::spawn(async move {
match update.download_and_install(|_, _| {}, || {}).await {
Ok(_) => {
if w.dialog()
.message("Would you like to restart the app?")
.title("Update Installed")
.buttons(MessageDialogButtons::OkCancelCustom(
"Restart".to_string(),
"Later".to_string(),
))
.blocking_show()
{
w.app_handle().restart();
}
}
Err(e) => {
w.dialog()
.message(format!("The update failed to install: {}", e));
}
}
});
});
}
match start_integrated_update(&w, &update).await {
Ok(UpdateResponseAction::Skip) => {
info!("Confirmed {}: skipped", update.version);
}
Ok(UpdateResponseAction::Install) => {
info!("Confirmed {}: install", update.version);
if let Err(e) = install_update_maybe_download(&w, &update).await {
error!("Failed to install: {e}");
return;
};
info!("Installed {}", update.version);
finish_integrated_update(&w, &update).await;
}
Err(e) => {
warn!("Failed to notify frontend, falling back: {e}",);
start_native_update(&w, &update).await;
}
};
});
true
}
};
@@ -158,6 +168,7 @@ impl YaakUpdater {
pub async fn maybe_check<R: Runtime>(
&mut self,
window: &WebviewWindow<R>,
auto_download: bool,
mode: UpdateMode,
) -> Result<bool> {
let update_period_seconds = match mode {
@@ -171,11 +182,206 @@ impl YaakUpdater {
return Ok(false);
}
// Don't check if dev
// Don't check if development (can still with manual user trigger)
if is_dev() {
return Ok(false);
}
self.check_now(window, mode, UpdateTrigger::Background).await
self.check_now(window, mode, auto_download, UpdateTrigger::Background).await
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
struct UpdateInfo {
reply_event_id: String,
version: String,
downloaded: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
#[serde(rename_all = "camelCase", tag = "type")]
#[ts(export, export_to = "index.ts")]
enum UpdateResponse {
Ack,
Action { action: UpdateResponseAction },
}
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "index.ts")]
enum UpdateResponseAction {
Install,
Skip,
}
async fn finish_integrated_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
if let Err(e) = window.emit_to(window.label(), "update_installed", update.version.to_string()) {
warn!("Failed to notify frontend of update install: {}", e);
}
}
async fn start_integrated_update<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<UpdateResponseAction> {
let download_path = ensure_download_path(window, update)?;
debug!("Download path: {}", download_path.display());
let downloaded = download_path.exists();
let ack_wait = Duration::from_secs(3);
let reply_id = generate_id();
// 1) Start listening BEFORE emitting to avoid missing a fast reply
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UpdateResponse>();
let w_for_listener = window.clone();
let event_id = w_for_listener.listen(reply_id.clone(), move |ev| {
match serde_json::from_str::<UpdateResponse>(ev.payload()) {
Ok(UpdateResponse::Ack) => {
let _ = tx.send(UpdateResponse::Ack);
}
Ok(UpdateResponse::Action { action }) => {
let _ = tx.send(UpdateResponse::Action { action });
}
Err(e) => {
warn!("Failed to parse update reply from frontend: {e:?}");
}
}
});
// Make sure we always unlisten
struct Unlisten<'a, R: Runtime> {
win: &'a WebviewWindow<R>,
id: tauri::EventId,
}
impl<'a, R: Runtime> Drop for Unlisten<'a, R> {
fn drop(&mut self) {
self.win.unlisten(self.id);
}
}
let _guard = Unlisten {
win: window,
id: event_id,
};
// 2) Emit the event now that listener is in place
let info = UpdateInfo {
version: update.version.to_string(),
downloaded,
reply_event_id: reply_id,
};
window
.emit_to(window.label(), "update_available", &info)
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
// 3) Two-stage timeout: first wait for ack, then wait for final action
// --- Phase 1: wait for ACK with timeout ---
let ack_timer = sleep(ack_wait);
tokio::pin!(ack_timer);
loop {
tokio::select! {
msg = rx.recv() => match msg {
Some(UpdateResponse::Ack) => break, // proceed to Phase 2
Some(UpdateResponse::Action{action}) => return Ok(action), // user was fast
None => return Err(GenericError("frontend channel closed before ack".into())),
},
_ = &mut ack_timer => {
return Err(GenericError("timed out waiting for frontend ack".into()));
}
}
}
// --- Phase 2: wait forever for final action ---
loop {
match rx.recv().await {
Some(UpdateResponse::Action { action }) => return Ok(action),
Some(UpdateResponse::Ack) => { /* ignore extra acks */ }
None => return Err(GenericError("frontend channel closed before action".into())),
}
}
}
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
// If the frontend doesn't respond, fallback to native dialogs
let confirmed = window
.dialog()
.message(format!(
"{} is available. Would you like to download and install it now?",
update.version
))
.buttons(MessageDialogButtons::OkCancelCustom("Download".to_string(), "Later".to_string()))
.title("Update Available")
.blocking_show();
if !confirmed {
return;
}
match update.download_and_install(|_, _| {}, || {}).await {
Ok(()) => {
if window
.dialog()
.message("Would you like to restart the app?")
.title("Update Installed")
.buttons(MessageDialogButtons::OkCancelCustom(
"Restart".to_string(),
"Later".to_string(),
))
.blocking_show()
{
window.app_handle().request_restart();
}
}
Err(e) => {
window.dialog().message(format!("The update failed to install: {}", e));
}
}
}
pub async fn download_update_idempotent<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<PathBuf> {
let dl_path = ensure_download_path(window, update)?;
if dl_path.exists() {
info!("{} already downloaded to {}", update.version, dl_path.display());
return Ok(dl_path);
}
info!("{} downloading: {}", update.version, dl_path.display());
let dl_bytes = update.download(|_, _| {}, || {}).await?;
std::fs::write(&dl_path, dl_bytes)
.map_err(|e| GenericError(format!("Failed to write update: {e}")))?;
info!("{} downloaded", update.version);
Ok(dl_path)
}
pub async fn install_update_maybe_download<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<()> {
let dl_path = download_update_idempotent(window, update).await?;
let update_bytes = std::fs::read(&dl_path)?;
update.install(update_bytes.as_slice())?;
Ok(())
}
pub fn ensure_download_path<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<PathBuf> {
// Ensure dir exists
let base_dir = window.path().app_cache_dir()?.join("updates");
std::fs::create_dir_all(&base_dir)?;
// Generate name based on signature
let sig_digest = md5::compute(&update.signature);
let name = format!("yaak-{}-{:x}", update.version, sig_digest);
let dl_path = base_dir.join(name);
Ok(dl_path)
}

View File

@@ -47,6 +47,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
message: format!("Installed {name}@{}", pv.version),
color: Some(Color::Success),
icon: None,
timeout: Some(5000),
},
)?;
}
@@ -90,6 +91,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
message: "Failed to import data".to_string(),
color: Some(Color::Danger),
icon: None,
timeout: None,
},
)?;
return Ok(());
@@ -103,6 +105,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
message: format!("Imported data for {} workspaces", results.workspaces.len()),
color: Some(Color::Success),
icon: None,
timeout: Some(5000),
},
)?;
}

View File

@@ -6,6 +6,7 @@ use tauri::{
};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::mpsc;
use crate::error::Result;
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
@@ -32,9 +33,9 @@ pub(crate) struct CreateWindowConfig<'s> {
pub(crate) fn create_window<R: Runtime>(
handle: &AppHandle<R>,
config: CreateWindowConfig,
) -> WebviewWindow<R> {
) -> Result<WebviewWindow<R>> {
#[allow(unused_variables)]
let menu = app_menu(handle).unwrap();
let menu = app_menu(handle)?;
// This causes the window to not be clickable (in AppImage), so disable on Linux
#[cfg(not(target_os = "linux"))]
@@ -55,12 +56,13 @@ pub(crate) fn create_window<R: Runtime>(
#[cfg(not(target_os = "macos"))]
{
use std::fs;
let dir = handle.path().temp_dir().unwrap().join("yaak_sessions").join(key);
fs::create_dir_all(dir.clone()).unwrap();
let safe_key = format!("{:x}", md5::compute(key.as_bytes()));
let dir = handle.path().app_data_dir()?.join("window-sessions").join(safe_key);
fs::create_dir_all(&dir)?;
win_builder = win_builder.data_directory(dir);
}
// macOS doesn't support data dir so must use this fn instead
// macOS doesn't support `data_directory()` so must use this fn instead
#[cfg(target_os = "macos")]
{
let hash = md5::compute(key.as_bytes());
@@ -108,11 +110,11 @@ pub(crate) fn create_window<R: Runtime>(
if let Some(w) = handle.webview_windows().get(config.label) {
info!("Webview with label {} already exists. Focusing existing", config.label);
w.set_focus().unwrap();
return w.to_owned();
w.set_focus()?;
return Ok(w.to_owned());
}
let win = win_builder.build().unwrap();
let win = win_builder.build()?;
if let Some(tx) = config.close_tx {
win.on_window_event(move |event| match event {
@@ -174,10 +176,10 @@ pub(crate) fn create_window<R: Runtime>(
}
});
win
Ok(win)
}
pub(crate) fn create_main_window(handle: &AppHandle, url: &str) -> WebviewWindow {
pub(crate) fn create_main_window(handle: &AppHandle, url: &str) -> Result<WebviewWindow> {
let mut counter = 0;
let label = loop {
let label = format!("{MAIN_WINDOW_PREFIX}{counter}");
@@ -211,7 +213,7 @@ pub(crate) fn create_child_window(
label: &str,
title: &str,
inner_size: (f64, f64),
) -> WebviewWindow {
) -> Result<WebviewWindow> {
let app_handle = parent_window.app_handle();
let label = format!("{OTHER_WINDOW_PREFIX}_{label}");
let scale_factor = parent_window.scale_factor().unwrap();
@@ -235,7 +237,7 @@ pub(crate) fn create_child_window(
..Default::default()
};
let child_window = create_window(&app_handle, config);
let child_window = create_window(&app_handle, config)?;
// NOTE: These listeners will remain active even when the windows close. Unfortunately,
// there's no way to unlisten to events for now, so we just have to be defensive.
@@ -272,5 +274,5 @@ pub(crate) fn create_child_window(
});
}
child_window
Ok(child_window)
}

BIN
src-tauri/static/greg.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,38 @@
{
"build": {
"features": [
"updater",
"license"
]
},
"app": {
"security": {
"capabilities": [
"default",
{
"identifier": "release",
"windows": [
"*"
],
"permissions": [
"yaak-license:default"
]
}
]
}
},
"plugins": {
"updater": {
"endpoints": [
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
}
},
"bundle": {
"createUpdaterArtifacts": "v1Compatible",
"windows": {
"signCommand": "trusted-signing-cli -e https://eus.codesigning.azure.net/ -a Yaak -c yaakapp %1"
}
}
}

View File

@@ -15,7 +15,8 @@
"enable": true,
"scope": {
"allow": [
"$APPDATA/responses/*"
"$APPDATA/responses/*",
"$RESOURCE/static/*"
]
}
}
@@ -28,12 +29,6 @@
"yaak"
]
}
},
"updater": {
"endpoints": [
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
}
},
"bundle": {
@@ -56,6 +51,7 @@
],
"longDescription": "A cross-platform desktop app for interacting with REST, GraphQL, and gRPC",
"resources": [
"static",
"vendored/protoc/include",
"vendored/plugins",
"vendored/plugin-runtime"
@@ -69,15 +65,11 @@
"nsis",
"rpm"
],
"createUpdaterArtifacts": "v1Compatible",
"macOS": {
"minimumSystemVersion": "13.0",
"exceptionDomain": "",
"entitlements": "macos/entitlements.plist",
"frameworks": []
},
"windows": {
"signCommand": "trusted-signing-cli -e https://eus.codesigning.azure.net/ -a Yaak -c yaakapp %1"
}
}
}

View File

@@ -9,7 +9,7 @@ publish = false
base32 = "0.5.1" # For encoding human-readable key
base64 = "0.22.1" # For encoding in the database
chacha20poly1305 = "0.10.1"
keyring = { version = "4.0.0-rc.1" }
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
log = "0.4.26"
serde = { workspace = true, features = ["derive"] }
tauri = { workspace = true }

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, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { appInfo } from '@yaakapp/app/lib/appInfo';
import { useEffect } from 'react';
import { LicenseCheckStatus } from './bindings/license';
export * from './bindings/license';
const CHECK_QUERY_KEY = ['license.check'];
export function useLicense() {
const queryClient = useQueryClient();
const activate = useMutation<void, string, { licenseKey: string }>({
@@ -30,12 +33,16 @@ export function useLicense() {
};
}, []);
const CHECK_QUERY_KEY = ['license.check'];
const check = useQuery<void, string, LicenseCheckStatus>({
const check = useQuery<LicenseCheckStatus | null, string>({
refetchInterval: 1000 * 60 * 60 * 12, // Refetch every 12 hours
refetchOnWindowFocus: false,
queryKey: CHECK_QUERY_KEY,
queryFn: () => invoke('plugin:yaak-license|check'),
queryFn: async () => {
if (!appInfo.featureLicense) {
return null;
}
return invoke<LicenseCheckStatus>('plugin:yaak-license|check');
},
});
return {

View File

@@ -155,6 +155,7 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
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));

View File

@@ -14,7 +14,7 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -62,7 +62,7 @@ export type ProxySetting = { "type": "enabled", http: string, https: string, aut
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, autoupdate: boolean, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };

View File

@@ -0,0 +1,10 @@
ALTER TABLE settings
ADD COLUMN hide_license_badge BOOLEAN DEFAULT FALSE;
-- 2. Backfill based on old JSON
UPDATE settings
SET hide_license_badge = 1
WHERE EXISTS ( SELECT 1
FROM key_values kv
WHERE kv.key = 'license_confirmation'
AND JSON_EXTRACT(kv.value, '$.confirmedPersonalUse') = TRUE );

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN auto_download_updates BOOLEAN DEFAULT TRUE;

View File

@@ -29,9 +29,6 @@ pub enum Error {
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
MultipleBaseEnvironments(String),
#[error("Multiple folder environments for {0}. Delete duplicates before continuing.")]
MultipleFolderEnvironments(String),
#[error("unknown error")]
Unknown,

View File

@@ -120,7 +120,9 @@ pub struct Settings {
pub theme_dark: String,
pub theme_light: String,
pub update_channel: String,
pub hide_license_badge: bool,
pub autoupdate: bool,
pub auto_download_updates: bool,
}
impl UpsertModelInfo for Settings {
@@ -169,7 +171,9 @@ impl UpsertModelInfo for Settings {
(ThemeDark, self.theme_dark.as_str().into()),
(ThemeLight, self.theme_light.as_str().into()),
(UpdateChannel, self.update_channel.into()),
(HideLicenseBadge, self.hide_license_badge.into()),
(Autoupdate, self.autoupdate.into()),
(AutoDownloadUpdates, self.auto_download_updates.into()),
(ColoredMethods, self.colored_methods.into()),
(Proxy, proxy.into()),
])
@@ -192,7 +196,9 @@ impl UpsertModelInfo for Settings {
SettingsIden::ThemeDark,
SettingsIden::ThemeLight,
SettingsIden::UpdateChannel,
SettingsIden::HideLicenseBadge,
SettingsIden::Autoupdate,
SettingsIden::AutoDownloadUpdates,
SettingsIden::ColoredMethods,
]
}
@@ -223,6 +229,8 @@ impl UpsertModelInfo for Settings {
hide_window_controls: row.get("hide_window_controls")?,
update_channel: row.get("update_channel")?,
autoupdate: row.get("autoupdate")?,
auto_download_updates: row.get("auto_download_updates")?,
hide_license_badge: row.get("hide_license_badge")?,
colored_methods: row.get("colored_methods")?,
})
}
@@ -533,10 +541,15 @@ pub struct Environment {
pub name: String,
pub public: bool,
pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>,
#[deprecated(
note = "parent_model is used instead. This field will be removed when schema field is added for sync/export."
)]
#[ts(skip)]
pub base: bool,
pub parent_model: String,
pub parent_id: Option<String>,
pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>,
}
impl UpsertModelInfo for Environment {
@@ -595,6 +608,8 @@ impl UpsertModelInfo for Environment {
Self: Sized,
{
let variables: String = row.get("variables")?;
let parent_model = row.get("parent_model")?;
let base = parent_model == "workspace";
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -602,11 +617,16 @@ impl UpsertModelInfo for Environment {
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
parent_id: row.get("parent_id")?,
parent_model: row.get("parent_model")?,
parent_model,
color: row.get("color")?,
name: row.get("name")?,
public: row.get("public")?,
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),
// Deprecated field, but we need to keep it around for a couple of versions
// for compatibility because sync/export don't have a schema field
#[allow(deprecated)]
base,
})
}
}

View File

@@ -1,11 +1,9 @@
use crate::db_context::DbContext;
use crate::error::Error::{
MissingBaseEnvironment, MultipleBaseEnvironments, MultipleFolderEnvironments,
};
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
use crate::error::Result;
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
use crate::util::UpdateSource;
use log::info;
use log::{info, warn};
impl<'a> DbContext<'a> {
pub fn get_environment(&self, id: &str) -> Result<Environment> {
@@ -13,12 +11,10 @@ impl<'a> DbContext<'a> {
}
pub fn get_environment_by_folder_id(&self, folder_id: &str) -> Result<Option<Environment>> {
let environments: Vec<Environment> =
let mut environments: Vec<Environment> =
self.find_many(EnvironmentIden::ParentId, folder_id, None)?;
if environments.len() > 1 {
return Err(MultipleFolderEnvironments(folder_id.to_string()));
}
// Sort so we return the most recently updated environment
environments.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(environments.get(0).cloned())
}
@@ -26,7 +22,7 @@ impl<'a> DbContext<'a> {
let environments = self.list_environments_ensure_base(workspace_id)?;
let base_environments = environments
.into_iter()
.filter(|e| e.parent_id.is_none())
.filter(|e| e.parent_model == "workspace")
.collect::<Vec<Environment>>();
if base_environments.len() > 1 {
@@ -44,13 +40,14 @@ impl<'a> DbContext<'a> {
let mut environments =
self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?;
let base_environment = environments.iter().find(|e| e.parent_id.is_none());
let base_environment = environments.iter().find(|e| e.parent_model == "workspace");
if let None = base_environment {
let e = self.upsert_environment(
&Environment {
workspace_id: workspace_id.to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
&UpdateSource::Background,
@@ -90,6 +87,23 @@ impl<'a> DbContext<'a> {
self.upsert_environment(&environment, source)
}
/// Find other environments with the same parent folder
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
if environment.parent_model != "folder" {
return Vec::new();
}
self.list_environments_ensure_base(&environment.workspace_id)
.unwrap_or_default()
.into_iter()
.filter(|e| {
e.id != environment.id
&& e.parent_model == "folder"
&& e.parent_id == environment.parent_id
})
.collect()
}
pub fn upsert_environment(
&self,
environment: &Environment,
@@ -101,8 +115,31 @@ impl<'a> DbContext<'a> {
.filter(|v| !v.name.is_empty() || !v.value.is_empty())
.cloned()
.collect::<Vec<EnvironmentVariable>>();
// Sometimes a new environment can be created via sync/import, so we'll just delete
// the others when that happens. Not the best, but it's good for now.
let duplicates = self.list_duplicate_folder_environments(environment);
for duplicate in duplicates {
warn!(
"Deleting duplicate environment {} for folder {:?}",
duplicate.id, environment.parent_id
);
_ = self.delete(&duplicate, source);
}
// Automatically update the environment name based on the folder name
let mut name = environment.name.clone();
match (environment.parent_model.as_str(), environment.parent_id.as_deref()) {
("folder", Some(folder_id)) => {
let folder = self.get_folder(folder_id)?;
name = format!("{} Environment", folder.name);
}
_ => {}
}
self.upsert(
&Environment {
name,
variables: cleaned_variables,
..environment.clone()
},

View File

@@ -33,6 +33,8 @@ impl<'a> DbContext<'a> {
update_channel: "stable".to_string(),
autoupdate: true,
colored_methods: false,
hide_license_badge: false,
auto_download_updates: true,
};
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
}

View File

@@ -7,8 +7,11 @@ publish = false
[dependencies]
base64 = "0.22.1"
chrono = { workspace = true }
dunce = "1.0.4"
futures-util = "0.3.30"
hex = { workspace = true }
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
log = "0.4.21"
md5 = "0.7.0"
path-slash = "0.2.1"
@@ -17,20 +20,18 @@ regex = "1.10.6"
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tauri = { workspace = true }
tauri-plugin-shell = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process"] }
tokio-tungstenite = "0.26.1"
ts-rs = { workspace = true, features = ["import-esm"] }
sha2 = { workspace = true }
yaak-common = { workspace = true }
yaak-crypto = { workspace = true }
yaak-models = { workspace = true }
yaak-templates = { workspace = true }
zip-extract = "0.4.0"
chrono = { workspace = true }
hex = { workspace = true }
[build-dependencies]
tauri-plugin = { workspace = true, features = ["build"] }

View File

@@ -437,7 +437,7 @@ export type SetKeyValueRequest = { key: string, value: string, };
export type SetKeyValueResponse = {};
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, };
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };
export type TemplateFunction = { name: string, description?: string,
/**

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, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -32,7 +32,7 @@ pub enum Error {
#[error("JSON error: {0}")]
JsonErr(#[from] serde_json::Error),
#[error("API Error: {0}")]
ApiErr(String),

View File

@@ -1,5 +1,4 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use tauri::{Runtime, WebviewWindow};
use ts_rs::TS;
@@ -163,7 +162,7 @@ pub enum InternalEventPayload {
impl InternalEventPayload {
pub fn type_name(&self) -> String {
if let Ok(Value::Object(map)) = serde_json::to_value(self) {
if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(self) {
map.get("type").map(|s| s.as_str().unwrap_or("unknown").to_string())
} else {
None
@@ -495,6 +494,9 @@ pub struct ShowToastRequest {
#[ts(optional)]
pub icon: Option<Icon>,
#[ts(optional)]
pub timeout: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]

View File

@@ -1,7 +1,7 @@
use std::sync::atomic::{AtomicBool, Ordering};
use crate::commands::{install, search, uninstall, updates};
use crate::manager::PluginManager;
use log::info;
use std::process::exit;
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{generate_handler, Manager, RunEvent, Runtime, State};
@@ -20,6 +20,8 @@ pub mod api;
pub mod install;
pub mod plugin_meta;
static EXITING: AtomicBool = AtomicBool::new(false);
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-plugins")
.invoke_handler(generate_handler![search, install, uninstall, updates])
@@ -31,12 +33,15 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.on_event(|app, e| match e {
// TODO: Also exit when app is force-quit (eg. cmd+r in IntelliJ runner)
RunEvent::ExitRequested { api, .. } => {
if EXITING.swap(true, Ordering::SeqCst) {
return; // Only exit once to prevent infinite recursion
}
api.prevent_exit();
tauri::async_runtime::block_on(async move {
info!("Exiting plugin runtime due to app exit");
let manager: State<PluginManager> = app.state();
manager.terminate().await;
exit(0);
app.exit(0);
});
}
_ => {}

View File

@@ -14,7 +14,7 @@ use crate::events::{
ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginWindowContext,
RenderPurpose,
};
use crate::native_template_functions::template_function_secure;
use crate::native_template_functions::{template_function_keyring, template_function_secure};
use crate::nodejs::start_nodejs_plugin_runtime;
use crate::plugin_handle::PluginHandle;
use crate::server_ws::PluginRuntimeServerWebsocket;
@@ -514,7 +514,7 @@ impl PluginManager {
// Add Rust-based functions
result.push(GetTemplateFunctionsResponse {
plugin_ref_id: "__NATIVE__".to_string(), // Meh
functions: vec![template_function_secure()],
functions: vec![template_function_secure(), template_function_keyring()],
});
Ok(result)

View File

@@ -5,6 +5,8 @@ use crate::events::{
use crate::template_callback::PluginTemplateCallback;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use keyring::Error::NoEntry;
use log::{debug, info};
use std::collections::HashMap;
use tauri::{AppHandle, Runtime};
use yaak_crypto::manager::EncryptionManagerExt;
@@ -32,6 +34,34 @@ pub(crate) fn template_function_secure() -> TemplateFunction {
}
}
pub(crate) fn template_function_keyring() -> TemplateFunction {
TemplateFunction {
name: "keychain".to_string(),
description: Some("Get a password from the OS keychain or keyring".to_string()),
aliases: Some(vec!["keyring".to_string()]),
args: vec![
TemplateFunctionArg::FormInput(FormInput::Text(FormInputText {
base: FormInputBase {
name: "service".to_string(),
label: Some("Service".to_string()),
description: Some("App or URL for the password".to_string()),
..Default::default()
},
..Default::default()
})),
TemplateFunctionArg::FormInput(FormInput::Text(FormInputText {
base: FormInputBase {
name: "account".to_string(),
label: Some("Account".to_string()),
description: Some("Username or email address".to_string()),
..Default::default()
},
..Default::default()
})),
],
}
}
pub fn template_function_secure_run<R: Runtime>(
app_handle: &AppHandle<R>,
args: HashMap<String, serde_json::Value>,
@@ -163,3 +193,25 @@ pub fn encrypt_secure_template_function<R: Runtime>(
)?
.to_string())
}
pub fn template_function_keychain_run(args: HashMap<String, serde_json::Value>) -> Result<String> {
let service = args.get("service").and_then(|v| v.as_str()).unwrap_or_default().to_owned();
let user = args.get("account").and_then(|v| v.as_str()).unwrap_or_default().to_owned();
debug!("Getting password for service {} and user {}", service, user);
let entry = match keyring::Entry::new(&service, &user) {
Ok(e) => e,
Err(e) => {
debug!("Failed to initialize keyring entry for '{}' and '{}' {:?}", service, user, e);
return Ok("".to_string()); // Don't fail for invalid args
}
};
match entry.get_password() {
Ok(p) => Ok(p),
Err(NoEntry) => {
info!("No password found for '{}' and '{}'", service, user);
Ok("".to_string()) // Don't fail for missing passwords
}
Err(e) => Err(RenderError(e.to_string())),
}
}

View File

@@ -1,12 +1,13 @@
use crate::events::{PluginWindowContext, RenderPurpose};
use crate::manager::PluginManager;
use crate::native_template_functions::{
template_function_secure_run, template_function_secure_transform_arg,
template_function_keychain_run, template_function_secure_run,
template_function_secure_transform_arg,
};
use std::collections::HashMap;
use tauri::{AppHandle, Manager, Runtime};
use yaak_templates::error::Result;
use yaak_templates::TemplateCallback;
use yaak_templates::error::Result;
#[derive(Clone)]
pub struct PluginTemplateCallback<R: Runtime> {
@@ -37,6 +38,8 @@ impl<R: Runtime> TemplateCallback for PluginTemplateCallback<R> {
if fn_name == "secure" {
return template_function_secure_run(&self.app_handle, args, &self.window_context);
} else if fn_name == "keychain" || fn_name == "keyring" {
return template_function_keychain_run(args);
}
let plugin_manager = &*self.app_handle.state::<PluginManager>();
@@ -51,12 +54,7 @@ impl<R: Runtime> TemplateCallback for PluginTemplateCallback<R> {
Ok(resp)
}
fn transform_arg(
&self,
fn_name: &str,
arg_name: &str,
arg_value: &str,
) -> Result<String> {
fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String> {
if fn_name == "secure" {
return template_function_secure_transform_arg(
&self.app_handle,

View File

@@ -19,6 +19,7 @@ thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "sync", "macros"] }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-models = { workspace = true }
serde_path_to_error = "0.1.20"
[build-dependencies]
tauri-plugin = { workspace = true, features = ["build"] }

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, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -2,7 +2,8 @@ use crate::error::Error::UnknownModel;
use crate::error::Result;
use chrono::NaiveDateTime;
use log::warn;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use serde_yaml::{Mapping, Value};
use sha1::{Digest, Sha1};
use std::fs;
use std::path::Path;
@@ -11,7 +12,7 @@ use yaak_models::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_models.ts")]
pub enum SyncModel {
@@ -23,6 +24,78 @@ pub enum SyncModel {
WebsocketRequest(WebsocketRequest),
}
impl<'de> Deserialize<'de> for SyncModel {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde_path_to_error as spte;
let mut v = Value::deserialize(deserializer)?;
let model = match v.get("model") {
Some(Value::String(model)) => model.clone(),
_ => "".to_string(),
};
let model = model.as_str();
let obj = v
.as_mapping_mut()
.ok_or_else(|| serde::de::Error::custom("expected object for SyncModel"))?;
// Dispatch to CHILD types (no recursion)
match model {
"workspace" => {
let x: Workspace = spte::deserialize(v).map_err(serde::de::Error::custom)?;
Ok(SyncModel::Workspace(x))
}
"environment" => {
migrate_environment(obj);
let x: Environment = spte::deserialize(v).map_err(serde::de::Error::custom)?;
Ok(SyncModel::Environment(x))
}
"folder" => {
let x: Folder = spte::deserialize(v).map_err(serde::de::Error::custom)?;
Ok(SyncModel::Folder(x))
}
"http_request" => {
let x: HttpRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?;
Ok(SyncModel::HttpRequest(x))
}
"grpc_request" => {
let x: GrpcRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?;
Ok(SyncModel::GrpcRequest(x))
}
"websocket_request" => {
let x: WebsocketRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?;
Ok(SyncModel::WebsocketRequest(x))
}
other => Err(serde::de::Error::unknown_variant(
other,
&[
"workspace",
"environment",
"folder",
"http_request",
"grpc_request",
"websocket_request",
],
)),
}
}
}
fn migrate_environment(obj: &mut Mapping) {
match obj.get("base") {
Some(Value::Bool(base)) => {
if *base {
obj.insert("parentModel".into(), "workspace".into());
} else {
obj.insert("parentModel".into(), "environment".into());
}
}
_ => {}
}
}
impl SyncModel {
pub fn from_bytes(content: Vec<u8>, file_path: &Path) -> Result<Option<(SyncModel, String)>> {
let mut hasher = Sha1::new();
@@ -145,3 +218,59 @@ impl TryFrom<AnyModel> for SyncModel {
Ok(m)
}
}
#[cfg(test)]
mod placeholder_tests {
use crate::error::Result;
use crate::models::SyncModel;
#[test]
fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> {
let raw = r#"
type: environment
model: environment
id: ev_fAUS49FUN2
workspaceId: wk_kfSI3JDHd7
createdAt: 2025-01-11T17:02:58.012792
updatedAt: 2025-07-23T20:00:46.049649
name: Global Variables
public: true
base: true
variables: []
color: null
"#;
let m: SyncModel = serde_yaml::from_str(raw)?;
match m {
SyncModel::Environment(env) => {
assert_eq!(env.parent_model, "workspace".to_string());
assert_eq!(env.parent_id, None);
}
_ => panic!("expected base environment"),
}
let raw = r#"
type: environment
model: environment
id: ev_fAUS49FUN2
workspaceId: wk_kfSI3JDHd7
createdAt: 2025-01-11T17:02:58.012792
updatedAt: 2025-07-23T20:00:46.049649
name: Global Variables
public: true
base: false
variables: []
color: null
"#;
let m: SyncModel = serde_yaml::from_str(raw)?;
match m {
SyncModel::Environment(env) => {
assert_eq!(env.parent_model, "environment".to_string());
assert_eq!(env.parent_id, None);
}
_ => panic!("expected sub environment"),
}
Ok(())
}
}

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import { appInfo } from '../lib/appInfo';
interface Props {
children: ReactNode;
feature: 'updater' | 'license';
}
const featureMap: Record<Props['feature'], boolean> = {
updater: appInfo.featureUpdater,
license: appInfo.featureLicense,
};
export function CargoFeature({ children, feature }: Props) {
if (featureMap[feature]) {
return <>{children}</>;
} else {
return null;
}
}

View File

@@ -51,7 +51,7 @@ export function ConfirmLargeResponse({ children, response }: Props) {
color="secondary"
variant="border"
size="xs"
text={() => getResponseBodyText({ responseId: response.id, filter: null })}
text={() => getResponseBodyText({ response, filter: null })}
/>
)}
</HStack>

View File

@@ -17,14 +17,14 @@ import { DismissibleBanner } from './core/DismissibleBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor.util';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
export function EnvironmentEditor({
environment: selectedEnvironment,
environment,
hideName,
className,
}: {
@@ -32,7 +32,7 @@ export function EnvironmentEditor({
hideName?: boolean;
className?: string;
}) {
const workspaceId = selectedEnvironment.workspaceId;
const workspaceId = environment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
namespace: 'global',
@@ -41,15 +41,15 @@ export function EnvironmentEditor({
});
const { allEnvironments } = useEnvironmentsBreakdown();
const handleChange = useCallback(
(variables: PairWithId[]) => patchModel(selectedEnvironment, { variables }),
[selectedEnvironment],
(variables: PairWithId[]) => patchModel(environment, { variables }),
[environment],
);
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const options: GenericCompletionOption[] = [];
if (isBaseEnvironment(selectedEnvironment)) {
if (isBaseEnvironment(environment)) {
return { options };
}
@@ -59,8 +59,10 @@ export function EnvironmentEditor({
const containingEnvs = allEnvironments.filter((e) =>
e.variables.some((v) => v.name === name),
);
const isAlreadyInActive = containingEnvs.find((e) => e.id === selectedEnvironment.id);
if (isAlreadyInActive) continue;
const isAlreadyInActive = containingEnvs.find((e) => e.id === environment.id);
if (isAlreadyInActive) {
continue;
}
options.push({
label: name,
type: 'constant',
@@ -68,7 +70,7 @@ export function EnvironmentEditor({
});
}
return { options };
}, [selectedEnvironment, allEnvironments]);
}, [environment, allEnvironments]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet and is unusable
@@ -79,10 +81,8 @@ export function EnvironmentEditor({
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
const allVariableAreEncrypted = useMemo(
() =>
selectedEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
),
[selectedEnvironment.variables],
environment.variables.every((v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure'),
[environment.variables],
);
const encryptEnvironment = (environment: Environment) => {
@@ -100,11 +100,11 @@ export function EnvironmentEditor({
return (
<VStack space={4} className={className}>
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorIndicator clickToEdit environment={selectedEnvironment ?? null} />
{!hideName && <div className="mr-2">{selectedEnvironment?.name}</div>}
<EnvironmentColorIndicator clickToEdit environment={environment ?? null} />
{!hideName && <div className="mr-2">{environment?.name}</div>}
{isEncryptionEnabled ? (
!allVariableAreEncrypted ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(selectedEnvironment)}>
<BadgeButton color="notice" onClick={() => encryptEnvironment(environment)}>
Encrypt All Variables
</BadgeButton>
) : (
@@ -121,22 +121,22 @@ export function EnvironmentEditor({
color="secondary"
rightSlot={<EnvironmentSharableTooltip />}
onClick={async () => {
await patchModel(selectedEnvironment, { public: !selectedEnvironment.public });
await patchModel(environment, { public: !environment.public });
}}
>
{selectedEnvironment.public ? 'Sharable' : 'Private'}
{environment.public ? 'Sharable' : 'Private'}
</BadgeButton>
</Heading>
{selectedEnvironment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
{environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
<DismissibleBanner
id={`warn-unencrypted-${selectedEnvironment.id}`}
id={`warn-unencrypted-${environment.id}`}
color="notice"
className="mr-3"
actions={[
{
label: 'Encrypt Variables',
onClick: () => encryptEnvironment(selectedEnvironment),
color: 'primary',
onClick: () => encryptEnvironment(environment),
color: 'success',
},
]}
>
@@ -151,17 +151,13 @@ export function EnvironmentEditor({
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteVariables='environment'
valueAutocompleteFunctions
forceUpdateKey={`${selectedEnvironment.id}::${forceUpdateKey}`}
pairs={selectedEnvironment.variables}
forceUpdateKey={`${environment.id}::${forceUpdateKey}`}
pairs={environment.variables}
onChange={handleChange}
stateKey={`environment.${selectedEnvironment.id}`}
forcedEnvironmentId={
// Editing the base environment should resolve variables using the active environment.
// Editing a sub environment should resolve variables as if it's the active environment
isBaseEnvironment(selectedEnvironment) ? undefined : selectedEnvironment.id
}
stateKey={`environment.${environment.id}`}
forcedEnvironmentId={environment.id}
/>
</div>
</VStack>

View File

@@ -7,6 +7,7 @@ import { useActiveRequest } from '../hooks/useActiveRequest';
import { useGrpc } from '../hooks/useGrpc';
import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles';
import { activeGrpcConnectionAtom, useGrpcEvents } from '../hooks/usePinnedGrpcConnection';
import { workspaceLayoutAtom } from '../lib/atoms';
import { Banner } from './core/Banner';
import { HotKeyList } from './core/HotKeyList';
import { SplitLayout } from './core/SplitLayout';
@@ -20,6 +21,7 @@ interface Props {
const emptyArray: string[] = [];
export function GrpcConnectionLayout({ style }: Props) {
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
const activeRequest = useActiveRequest('grpc_request');
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
const grpcEvents = useGrpcEvents(activeConnection?.id ?? null);
@@ -80,6 +82,7 @@ export function GrpcConnectionLayout({ style }: Props) {
name="grpc_layout"
className="p-3 gap-1.5"
style={style}
layout={workspaceLayout}
firstSlot={({ style }) => (
<GrpcRequestPane
style={style}

View File

@@ -10,7 +10,8 @@ import { DetailsBanner } from './core/DetailsBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { InputProps } from './core/Input';
import type { Pair, PairEditorProps } from './core/PairEditor';
import { ensurePairId, PairEditorRow } from './core/PairEditor';
import { PairEditorRow } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor.util';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { HStack } from './core/Stacks';

View File

@@ -47,7 +47,9 @@ export function HttpAuthenticationEditor({ model }: Props) {
if (model.authenticationType != null && authConfig.data == null) {
return (
<EmptyStateText>
Unknown authentication <InlineCode>{authConfig.data}</InlineCode>
<p>
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
</p>
</EmptyStateText>
);
}

View File

@@ -4,6 +4,7 @@ import { useAtomValue } from 'jotai';
import type { CSSProperties } from 'react';
import React from 'react';
import { useCurrentGraphQLSchema } from '../hooks/useIntrospectGraphQL';
import { workspaceLayoutAtom } from '../lib/atoms';
import type { SlotProps } from './core/SplitLayout';
import { SplitLayout } from './core/SplitLayout';
import { GraphQLDocsExplorer } from './graphql/GraphQLDocsExplorer';
@@ -16,15 +17,18 @@ interface Props {
style: CSSProperties;
}
export function HttpRequestLayout({ activeRequest, style }: Props) {
const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom);
const graphQLSchema = useCurrentGraphQLSchema(activeRequest);
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
const requestResponseSplit = ({ style }: Pick<SlotProps, 'style'>) => (
<SplitLayout
name="http_layout"
className="p-3 gap-1.5"
style={style}
layout={workspaceLayout}
firstSlot={({ orientation, style }) => (
<HttpRequestPane
style={style}

View File

@@ -1,9 +1,10 @@
import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license';
import { settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../lib/appInfo';
import { useLicenseConfirmation } from '../hooks/useLicenseConfirmation';
import { CargoFeature } from './CargoFeature';
import { BadgeButton } from './core/BadgeButton';
import type { ButtonProps } from './core/Button';
@@ -14,37 +15,34 @@ const details: Record<
commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' },
personal_use: { label: 'Personal Use', color: 'notice' },
trialing: { label: 'Personal Use', color: 'info' },
trialing: { label: 'Trialing', color: 'info' },
};
export function LicenseBadge() {
const { check } = useLicense();
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
return (
<CargoFeature feature="license">
<LicenseBadgeCmp />
</CargoFeature>
);
}
if (appInfo.isDev) {
return null;
}
function LicenseBadgeCmp() {
const { check } = useLicense();
const settings = useAtomValue(settingsAtom);
if (check.error) {
return (
<BadgeButton color="danger" onClick={() => openSettings.mutate('license')}>
License Error
</BadgeButton>
);
// Failed to check for license. Probably a network or server error so just don't
// show anything.
return null;
}
// Hasn't loaded yet
if (licenseDetails == null || check.data == null) {
if (check.data == null) {
return null;
}
// User has confirmed they are using Yaak for personal use only, so hide badge
if (licenseDetails.confirmedPersonalUse) {
return null;
}
// User is trialing but has already seen the message, so hide badge
if (check.data.type === 'trialing' && licenseDetails.hasDismissedTrial) {
// Dismissed license badge
if (settings.hideLicenseBadge) {
return null;
}
@@ -56,15 +54,7 @@ export function LicenseBadge() {
return (
<BadgeButton
color={detail.color}
onClick={async () => {
if (check.data.type === 'trialing') {
await setLicenseDetails((v) => ({
...v,
hasDismissedTrial: true,
}));
}
openSettings.mutate('license');
}}
onClick={() => openSettings.mutate('license')}
>
{detail.label}
</BadgeButton>

View File

@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { convertFileSrc } from '@tauri-apps/api/core';
import { resolveResource } from '@tauri-apps/api/path';
import classNames from 'classnames';
import React from 'react';
interface Props {
src: string;
className?: string;
}
export function LocalImage({ src: srcPath, className }: Props) {
const src = useQuery({
queryKey: ['local-image', srcPath],
queryFn: async () => {
const p = await resolveResource(srcPath);
console.log("LOADING SRC", srcPath, p)
return convertFileSrc(p);
},
});
return (
<img
src={src.data}
alt="Response preview"
className={classNames(
className,
'transition-opacity',
src.data == null ? 'opacity-0' : 'opacity-100',
)}
/>
);
}

View File

@@ -4,8 +4,10 @@ import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import React, { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize';
import { HStack } from '../core/Stacks';
import type { TabItem } from '../core/Tabs/Tabs';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize';
import { SettingsInterface } from './SettingsInterface';
@@ -72,24 +74,30 @@ export default function Settings({ hide }: Props) {
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) }))}
tabs={tabs.map(
(value): TabItem => ({
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
}),
)}
>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-8 !py-4">
<SettingsGeneral />
</TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-8 !py-4">
<SettingsInterface />
</TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-8 !py-4">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full px-4 grid grid-rows-1">
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-8 !py-4">
<SettingsPlugins />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-8 !py-4">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-8 !py-4">
<SettingsLicense />
</TabContent>
</Tabs>

View File

@@ -6,6 +6,7 @@ import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { appInfo } from '../../lib/appInfo';
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';
import { revealInFinderText } from '../../lib/reveal';
import { CargoFeature } from '../CargoFeature';
import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading';
import { IconButton } from '../core/IconButton';
@@ -26,43 +27,56 @@ export function SettingsGeneral() {
return (
<VStack space={1.5} className="mb-4">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<CargoFeature feature="updater">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<Select
name="updateChannel"
label="Update Channel"
labelPosition="left"
labelClassName="w-[14rem]"
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
options={[
{ label: 'Stable', value: 'stable' },
{ label: 'Beta (more frequent)', value: 'beta' },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
<Select
name="updateChannel"
label="Update Channel"
name="autoupdate"
value={settings.autoupdate ? 'auto' : 'manual'}
label="Update Behavior"
labelPosition="left"
labelClassName="w-[14rem]"
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
labelClassName="w-[14rem]"
onChange={(v) => patchModel(settings, { autoupdate: v === 'auto' })}
options={[
{ label: 'Stable', value: 'stable' },
{ label: 'Beta (more frequent)', value: 'beta' },
{ label: 'Automatic', value: 'auto' },
{ label: 'Manual', value: 'manual' },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
<Checkbox
className="pl-2 mt-1 ml-[14rem]"
checked={settings.autoDownloadUpdates}
disabled={!settings.autoupdate}
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
title="Automatically download updates"
onChange={(autoDownloadUpdates) =>
patchModel(settings, { autoDownloadUpdates })
}
/>
</div>
<Select
name="autoupdate"
value={settings.autoupdate ? 'auto' : 'manual'}
label="Update Behavior"
labelPosition="left"
size="sm"
labelClassName="w-[14rem]"
onChange={(v) => patchModel(settings, { autoupdate: v === 'auto' })}
options={[
{ label: 'Automatic', value: 'auto' },
{ label: 'Manual', value: 'manual' },
]}
/>
<Separator className="my-4" />
</CargoFeature>
<Select
name="switchWorkspaceBehavior"

View File

@@ -1,13 +1,17 @@
import { type } from '@tauri-apps/plugin-os';
import { useFonts } from '@yaakapp-internal/fonts';
import type { EditorKeymap } from '@yaakapp-internal/models';
import { useLicense } from '@yaakapp-internal/license';
import type { EditorKeymap, Settings } from '@yaakapp-internal/models';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import React from 'react';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { clamp } from '../../lib/clamp';
import { showConfirm } from '../../lib/confirm';
import { CargoFeature } from '../CargoFeature';
import { Checkbox } from '../core/Checkbox';
import { Icon } from '../core/Icon';
import { Link } from '../core/Link';
import { Select } from '../core/Select';
import { HStack, VStack } from '../core/Stacks';
@@ -123,6 +127,9 @@ export function SettingsInterface() {
title="Colorize Request Methods"
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
/>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
{type() !== 'macos' && (
<Checkbox
@@ -135,3 +142,44 @@ export function SettingsInterface() {
</VStack>
);
}
function LicenseSettings({ settings }: { settings: Settings }) {
const license = useLicense();
if (license.check.data?.type !== 'personal_use') {
return null;
}
return (
<Checkbox
checked={settings.hideLicenseBadge}
title="Hide personal use badge"
onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) {
const confirmed = await showConfirm({
id: 'hide-license-badge',
title: 'Confirm Personal Use',
confirmText: 'Confirm',
description: (
<VStack space={3}>
<p>Hey there 👋🏼</p>
<p>
Yaak is free for personal projects and learning.{' '}
<strong>If youre using Yaak at work, a license is required.</strong>
</p>
<p>
Licenses help keep Yaak independent and sustainable.{' '}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
</p>
</VStack>
),
requireTyping: 'Personal Use',
color: 'info',
});
if (!confirmed) {
return; // Cancel
}
}
await patchModel(settings, { hideLicenseBadge });
}}
/>
);
}

View File

@@ -2,94 +2,95 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import { useLicense } from '@yaakapp-internal/license';
import { differenceInDays } from 'date-fns';
import React, { useState } from 'react';
import { useLicenseConfirmation } from '../../hooks/useLicenseConfirmation';
import { useToggle } from '../../hooks/useToggle';
import { pluralizeCount } from '../../lib/pluralize';
import { CargoFeature } from '../CargoFeature';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { Icon } from '../core/Icon';
import { Link } from '../core/Link';
import { PlainInput } from '../core/PlainInput';
import { HStack, VStack } from '../core/Stacks';
import { LocalImage } from '../LocalImage';
export function SettingsLicense() {
return (
<CargoFeature feature="license">
<SettingsLicenseCmp />
</CargoFeature>
);
}
function SettingsLicenseCmp() {
const { check, activate, deactivate } = useLicense();
const [key, setKey] = useState<string>('');
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
const [checked, setChecked] = useState<boolean>(false);
if (check.isPending) {
return null;
}
return (
<div className="flex flex-col gap-6 max-w-lg">
<div className="flex flex-col gap-6 max-w-xl">
{check.data?.type === 'commercial_use' ? (
<Banner color="success">
<strong>License active!</strong> Enjoy using Yaak for commercial use.
</Banner>
<Banner color="success">Your license is active 🥳</Banner>
) : check.data?.type == 'trialing' ? (
<Banner color="success" className="flex flex-col gap-3 max-w-lg">
<p className="select-text">
You have{' '}
<Banner color="info" className="flex flex-col gap-3 max-w-lg">
<p>
<strong>
{pluralizeCount('day', differenceInDays(check.data.end, new Date()))} remaining
</strong>{' '}
on your commercial use trial. Once the trial ends you agree to only use Yaak for
personal use until a license is activated.
on your commercial-use trial
</p>
</Banner>
) : check.data?.type == 'personal_use' && !licenseDetails?.confirmedPersonalUse ? (
<Banner color="success" className="flex flex-col gap-3 max-w-lg">
<p className="select-text">
Your 30-day trial has ended. Please activate a license or confirm how you&apos;re using
Yaak.
</p>
<form
className="flex flex-col gap-3 items-start"
onSubmit={async (e) => {
e.preventDefault();
await setLicenseDetails((v) => ({
...v,
confirmedPersonalUse: true,
}));
}}
>
<Checkbox
checked={checked}
onChange={setChecked}
title="I am only using Yaak for personal use"
/>
<Button type="submit" disabled={!checked} size="xs" variant="border" color="success">
Confirm
</Button>
</form>
) : check.data?.type == 'personal_use' ? (
<Banner color="notice" className="flex flex-col gap-3 max-w-lg">
<p>You are able to use Yaak for personal use only</p>
</Banner>
) : null}
<p className="select-text">
A commercial license is required if using Yaak within a for-profit organization.{' '}
<Link href="https://yaak.app/pricing" className="text-notice">
Learn More
</Link>
</p>
{check.data?.type !== 'commercial_use' && (
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-6 items-center my-3 ">
<LocalImage src="static/greg.jpeg" className="rounded-full h-20 w-20" />
<div className="flex flex-col gap-2">
<h2 className="text-lg font-bold">Hey, I&apos;m Greg 👋🏼</h2>
<p>
Yaak is free for personal projects and learning.{' '}
{check.data?.type === 'trialing' ? 'After your trial, a ' : 'A '}
license is required for work or commercial use.
</p>
<p>
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data?.type ?? ''}`}
className="text-sm text-notice opacity-80 hover:opacity-100"
>
Learn More
</Link>
</p>
</div>
</div>
)}
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
{check.data?.type === 'commercial_use' ? (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={() => {
deactivate.mutate();
}}>
<Button
variant="border"
color="secondary"
size="sm"
onClick={() => {
deactivate.mutate();
}}
>
Deactivate License
</Button>
<Button
color="secondary"
size="sm"
onClick={() => openUrl('https://yaak.app/dashboard')}
onClick={() => openUrl('https://yaak.app/dashboard?s=support&ref=app.yaak.desktop')}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
@@ -97,16 +98,20 @@ export function SettingsLicense() {
</HStack>
) : (
<HStack space={2}>
<Button color="primary" size="sm" onClick={toggleActivateFormVisible}>
Activate
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
Activate License
</Button>
<Button
color="secondary"
size="sm"
onClick={() => openUrl('https://yaak.app/pricing?ref=app.yaak.desktop')}
color="primary"
onClick={() =>
openUrl(
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.type ?? ''}`,
)
}
rightSlot={<Icon icon="external_link" />}
>
Purchase
Purchase License
</Button>
</HStack>
)}

View File

@@ -74,6 +74,7 @@ export function SettingsDropdown() {
{
label: 'Check for Updates',
leftSlot: <Icon icon="update" />,
hidden: !appInfo.featureUpdater,
onSelect: () => checkForUpdates.mutate(),
},
{

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