Compare commits

..

12 Commits

Author SHA1 Message Date
Gregory Schier
9ab02130b0 Fix sync import issues:
https://feedback.yaak.app/p/yaml-error-missing-field-type-at-line-4521-column-1
2025-06-27 13:32:52 -07:00
Gregory Schier
25d50246c0 Revert notification endpoint URL for dev 2025-06-27 11:58:04 -07:00
Gregory Schier
bb0cc16a70 Use API client for notifications/license 2025-06-25 08:17:17 -07:00
Gregory Schier
8817be679b Fix PKCE flow and clean up other flows 2025-06-25 07:10:11 -07:00
Gregory Schier
f476d87613 Add back unsigned memory entitlement 2025-06-24 06:21:07 -07:00
Gregory Schier
1438e8bacc Upgrade eslint and fix issues 2025-06-23 14:09:09 -07:00
Gregory Schier
7be2767527 Fix lint error 2025-06-23 09:51:44 -07:00
Gregory Schier
a1b1eafd39 Add links to plugins 2025-06-23 09:46:54 -07:00
Gregory Schier
1948fb78bd Fix bad import 2025-06-23 08:57:31 -07:00
Gregory Schier
cb7c44cc65 Install plugins from Yaak plugin registry (#230) 2025-06-23 08:55:38 -07:00
Gregory Schier
b5620fcdf3 Merge pull request #227
* Search and install plugins PoC

* Checksum

* Tab sidebar for settings

* Fix nested tabs, and tweaks

* Table for plugin results

* Deep links working

* Focus window during deep links

* Merge branch 'master' into plugin-directory

* More stuff
2025-06-22 07:06:43 -07:00
Mr0Bread
b8e6dbc7c7 GraphQL Documentation explorer (#208) 2025-06-17 17:08:39 -07:00
100 changed files with 3324 additions and 1374 deletions

View File

@@ -1,6 +0,0 @@
node_modules/
dist/
.eslintrc.cjs
.prettierrc.cjs
src-web/postcss.config.cjs
src-web/vite.config.ts

View File

@@ -1,49 +0,0 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier',
],
plugins: ['react-refresh'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.json'],
},
ignorePatterns: [
'scripts/**/*',
'packages/plugin-runtime/**/*',
'packages/plugin-runtime-types/**/*',
'src-tauri/**/*',
'src-web/tailwind.config.cjs',
'src-web/vite.config.ts',
],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src-web'],
extensions: ['.ts', '.tsx'],
},
},
},
rules: {
'react-refresh/only-export-components': 'error',
'jsx-a11y/no-autofocus': 'off',
'react/react-in-jsx-scope': 'off',
'import/no-unresolved': 'off',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
disallowTypeAnnotations: true,
fixStyle: 'separate-type-imports',
},
],
},
};

88
eslint.config.cjs Normal file
View File

@@ -0,0 +1,88 @@
const { defineConfig, globalIgnores } = require('eslint/config');
const { fixupConfigRules } = require('@eslint/compat');
const reactRefresh = require('eslint-plugin-react-refresh');
const tsParser = require('@typescript-eslint/parser');
const js = require('@eslint/js');
const { FlatCompat } = require('@eslint/eslintrc');
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
module.exports = defineConfig([
{
extends: fixupConfigRules(
compat.extends(
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier',
),
),
plugins: {
'react-refresh': reactRefresh,
},
languageOptions: {
parser: tsParser,
parserOptions: {
project: ['./tsconfig.json'],
},
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src-web'],
extensions: ['.ts', '.tsx'],
},
},
},
rules: {
'react-refresh/only-export-components': 'error',
'jsx-a11y/no-autofocus': 'off',
'react/react-in-jsx-scope': 'off',
'import/no-unresolved': 'off',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
disallowTypeAnnotations: true,
fixStyle: 'separate-type-imports',
},
],
},
},
globalIgnores([
'scripts/**/*',
'packages/plugin-runtime/**/*',
'packages/plugin-runtime-types/**/*',
'src-tauri/**/*',
'src-web/tailwind.config.cjs',
'src-web/vite.config.ts',
]),
globalIgnores([
'**/node_modules/',
'**/dist/',
'**/.eslintrc.cjs',
'**/.prettierrc.cjs',
'src-web/postcss.config.cjs',
'src-web/vite.config.ts',
]),
]);

1295
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,16 +67,19 @@
"jotai": "^2.12.2"
},
"devDependencies": {
"@eslint/compat": "^1.3.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.29.0",
"@tauri-apps/cli": "2.4.1",
"@typescript-eslint/eslint-plugin": "^8.27.0",
"@typescript-eslint/parser": "^8.27.0",
"@yaakapp/cli": "^0.1.5",
"eslint": "^8",
"eslint-config-prettier": "^8",
"eslint-plugin-import": "^2.31.0",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.4.2",

View File

@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginVersion } from "./gen_search.js";
export type PluginNameVersion = { name: string, version: string, };
export type PluginSearchResponse = { plugins: Array<PluginVersion>, };
export type PluginUpdatesResponse = { plugins: Array<PluginNameVersion>, };

View File

@@ -372,7 +372,7 @@ export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, windowContext: PluginWindowContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & EmptyPayload | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & BootResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type JsonPrimitive = string | number | boolean | null;

View File

@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };
export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };

View File

@@ -1,5 +1,6 @@
import {
BootRequest,
BootResponse,
DeleteKeyValueResponse,
FindHttpResponsesResponse,
FormInput,
@@ -52,9 +53,22 @@ export class PluginInstance {
// Reload plugin if the JS or package.json changes
const windowContextNone: PluginWindowContext = { type: 'none' };
this.#mod = {};
this.#pkg = JSON.parse(readFileSync(this.#pathPkg(), 'utf8'));
const bootResponse: BootResponse = {
name: this.#pkg.name ?? 'unknown',
version: this.#pkg.version ?? '0.0.1',
};
const fileChangeCallback = async () => {
this.#importModule();
return this.#sendPayload(windowContextNone, { type: 'reload_response' }, null);
return this.#sendPayload(
windowContextNone,
{ type: 'reload_response', ...bootResponse },
null,
);
};
if (this.#workerData.bootRequest.watch) {
@@ -62,12 +76,6 @@ export class PluginInstance {
watchFile(this.#pathPkg(), fileChangeCallback);
}
this.#mod = {};
this.#pkg = JSON.parse(readFileSync(this.#pathPkg(), 'utf8'));
// TODO: Re-implement this now that we're not using workers
// prefixStdout(`[plugin][${this.#pkg.name}] %s`);
this.#importModule();
}

View File

@@ -53,3 +53,7 @@ async function handleIncoming(msg: string) {
plugin.sendToWorker(pluginEvent);
}
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

View File

@@ -1,8 +1,8 @@
import { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
import type { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
import { readFileSync } from 'node:fs';
import { AccessTokenRawResponse } from './store';
import type { AccessTokenRawResponse } from './store';
export async function getAccessToken(
export async function fetchAccessToken(
ctx: Context,
{
accessTokenUrl,

View File

@@ -0,0 +1,19 @@
import type { Context } from '@yaakapp/api';
import type { AccessToken } from './store';
import { getToken } from './store';
export async function getAccessTokenIfNotExpired(
ctx: Context,
contextId: string,
): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
if (token == null || isTokenExpired(token)) {
return null;
}
return token;
}
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
}

View File

@@ -1,29 +1,34 @@
import { Context, HttpRequest } from '@yaakapp/api';
import type { Context, HttpRequest } from '@yaakapp/api';
import { readFileSync } from 'node:fs';
import { AccessToken, AccessTokenRawResponse, deleteToken, getToken, storeToken } from './store';
import { isTokenExpired } from './getAccessTokenIfNotExpired';
import type { AccessToken, AccessTokenRawResponse } from './store';
import { deleteToken, getToken, storeToken } from './store';
export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
scope,
accessTokenUrl,
credentialsInBody,
clientId,
clientSecret,
forceRefresh,
}: {
scope: string | null;
accessTokenUrl: string;
credentialsInBody: boolean;
clientId: string;
clientSecret: string;
forceRefresh?: boolean;
}): Promise<AccessToken | null> {
export async function getOrRefreshAccessToken(
ctx: Context,
contextId: string,
{
scope,
accessTokenUrl,
credentialsInBody,
clientId,
clientSecret,
forceRefresh,
}: {
scope: string | null;
accessTokenUrl: string;
credentialsInBody: boolean;
clientId: string;
clientSecret: string;
forceRefresh?: boolean;
},
): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
if (token == null) {
return null;
}
const now = Date.now();
const isExpired = token.expiresAt && now > token.expiresAt;
const isExpired = isTokenExpired(token);
// Return the current access token if it's still valid
if (!isExpired && !forceRefresh) {
@@ -79,7 +84,9 @@ export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
console.log('[oauth2] Got refresh token response', resp.status);
if (resp.status < 200 || resp.status >= 300) {
throw new Error('Failed to refresh access token with status=' + resp.status + ' and body=' + body);
throw new Error(
'Failed to refresh access token with status=' + resp.status + ' and body=' + body,
);
}
let response;
@@ -90,7 +97,9 @@ export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
}
if (response.error) {
throw new Error(`Failed to fetch access token with ${response.error} -> ${response.error_description}`);
throw new Error(
`Failed to fetch access token with ${response.error} -> ${response.error_description}`,
);
}
const newResponse: AccessTokenRawResponse = {

View File

@@ -1,8 +1,9 @@
import { Context } from '@yaakapp/api';
import type { Context } from '@yaakapp/api';
import { createHash, randomBytes } from 'node:crypto';
import { getAccessToken } from '../getAccessToken';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import { AccessToken, getDataDirKey, storeToken } from '../store';
import type { AccessToken } from '../store';
import { getDataDirKey, storeToken } from '../store';
export const PKCE_SHA256 = 'S256';
export const PKCE_PLAIN = 'plain';
@@ -34,8 +35,8 @@ export async function getAuthorizationCode(
audience: string | null;
credentialsInBody: boolean;
pkce: {
challengeMethod: string | null;
codeVerifier: string | null;
challengeMethod: string;
codeVerifier: string;
} | null;
tokenName: 'access_token' | 'id_token';
},
@@ -59,26 +60,25 @@ export async function getAuthorizationCode(
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
if (pkce) {
const verifier = pkce.codeVerifier || createPkceCodeVerifier();
const challengeMethod = pkce.challengeMethod || DEFAULT_PKCE_METHOD;
authorizationUrl.searchParams.set(
'code_challenge',
createPkceCodeChallenge(verifier, challengeMethod),
pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod),
);
authorizationUrl.searchParams.set('code_challenge_method', challengeMethod);
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
}
return new Promise(async (resolve, reject) => {
const authorizationUrlStr = authorizationUrl.toString();
const logsEnabled = (await ctx.store.get('enable_logs')) ?? false;
console.log('[oauth2] Authorizing', authorizationUrlStr);
const logsEnabled = (await ctx.store.get('enable_logs')) ?? false;
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing', authorizationUrlStr);
// eslint-disable-next-line no-async-promise-executor
const code = await new Promise<string>(async (resolve, reject) => {
let foundCode = false;
let { close } = await ctx.window.openUrl({
const { close } = await ctx.window.openUrl({
url: authorizationUrlStr,
label: 'oauth-authorization-url',
dataDirKey: await getDataDirKey(ctx, contextId),
dataDirKey,
async onClose() {
if (!foundCode) {
reject(new Error('Authorization window closed'));
@@ -89,6 +89,7 @@ export async function getAuthorizationCode(
if (logsEnabled) console.log('[oauth2] Navigated to', urlStr);
if (url.searchParams.has('error')) {
close();
return reject(new Error(`Failed to authorize: ${url.searchParams.get('error')}`));
}
@@ -101,37 +102,35 @@ export async function getAuthorizationCode(
// Close the window here, because we don't need it anymore!
foundCode = true;
close();
console.log('[oauth2] Code found');
const response = await getAccessToken(ctx, {
grantType: 'authorization_code',
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
],
});
try {
resolve(await storeToken(ctx, contextId, response, tokenName));
} catch (err) {
reject(err);
}
resolve(code);
},
});
});
console.log('[oauth2] Code found');
const response = await fetchAccessToken(ctx, {
grantType: 'authorization_code',
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
],
});
return storeToken(ctx, contextId, response, tokenName);
}
function createPkceCodeVerifier() {
export function genPkceCodeVerifier() {
return encodeForPkce(randomBytes(32));
}
function createPkceCodeChallenge(verifier: string, method: string) {
function pkceCodeChallenge(verifier: string, method: string) {
if (method === 'plain') {
return verifier;
}

View File

@@ -1,5 +1,6 @@
import { Context } from '@yaakapp/api';
import { getAccessToken } from '../getAccessToken';
import type { Context } from '@yaakapp/api';
import { fetchAccessToken } from '../fetchAccessToken';
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
import { getToken, storeToken } from '../store';
export async function getClientCredentials(
@@ -22,13 +23,11 @@ export async function getClientCredentials(
},
) {
const token = await getToken(ctx, contextId);
if (token) {
// resolve(token.response.access_token);
// TODO: Refresh token if expired
// return;
if (token && !isTokenExpired(token)) {
return token;
}
const response = await getAccessToken(ctx, {
const response = await fetchAccessToken(ctx, {
grantType: 'client_credentials',
accessTokenUrl,
audience,

View File

@@ -1,7 +1,9 @@
import { Context } from '@yaakapp/api';
import { AccessToken, AccessTokenRawResponse, getToken, storeToken } from '../store';
import type { Context } from '@yaakapp/api';
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
import type { AccessToken, AccessTokenRawResponse} from '../store';
import { getToken, storeToken } from '../store';
export function getImplicit(
export async function getImplicit(
ctx: Context,
contextId: string,
{
@@ -24,31 +26,30 @@ export function getImplicit(
tokenName: 'access_token' | 'id_token';
},
): Promise<AccessToken> {
return new Promise(async (resolve, reject) => {
const token = await getToken(ctx, contextId);
if (token) {
// resolve(token.response.access_token);
// TODO: Refresh token if expired
// return;
}
const token = await getToken(ctx, contextId);
if (token != null && !isTokenExpired(token)) {
return token;
}
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl.searchParams.set('response_type', 'token');
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
if (responseType.includes('id_token')) {
authorizationUrl.searchParams.set(
'nonce',
String(Math.floor(Math.random() * 9999999999999) + 1),
);
}
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl.searchParams.set('response_type', 'token');
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
if (responseType.includes('id_token')) {
authorizationUrl.searchParams.set(
'nonce',
String(Math.floor(Math.random() * 9999999999999) + 1),
);
}
const authorizationUrlStr = authorizationUrl.toString();
// eslint-disable-next-line no-async-promise-executor
const newToken = await new Promise<AccessToken>(async (resolve, reject) => {
let foundAccessToken = false;
let { close } = await ctx.window.openUrl({
const authorizationUrlStr = authorizationUrl.toString();
const { close } = await ctx.window.openUrl({
url: authorizationUrlStr,
label: 'oauth-authorization-url',
async onClose() {
@@ -76,11 +77,13 @@ export function getImplicit(
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try {
resolve(await storeToken(ctx, contextId, response));
resolve(storeToken(ctx, contextId, response));
} catch (err) {
reject(err);
}
},
});
});
return newToken;
}

View File

@@ -1,7 +1,8 @@
import { Context } from '@yaakapp/api';
import { getAccessToken } from '../getAccessToken';
import type { Context } from '@yaakapp/api';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import { AccessToken, storeToken } from '../store';
import type { AccessToken} from '../store';
import { storeToken } from '../store';
export async function getPassword(
ctx: Context,
@@ -37,7 +38,7 @@ export async function getPassword(
return token;
}
const response = await getAccessToken(ctx, {
const response = await fetchAccessToken(ctx, {
accessTokenUrl,
clientId,
clientSecret,

View File

@@ -1,4 +1,4 @@
import {
import type {
Context,
FormInputSelectOption,
GetHttpAuthenticationConfigRequest,
@@ -6,6 +6,7 @@ import {
PluginDefinition,
} from '@yaakapp/api';
import {
genPkceCodeVerifier,
DEFAULT_PKCE_METHOD,
getAuthorizationCode,
PKCE_PLAIN,
@@ -14,7 +15,8 @@ import {
import { getClientCredentials } from './grants/clientCredentials';
import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password';
import { AccessToken, deleteToken, getToken, resetDataDirKey } from './store';
import type { AccessToken } from './store';
import { deleteToken, getToken, resetDataDirKey } from './store';
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
@@ -219,9 +221,9 @@ export const plugin: PluginDefinition = {
},
{
type: 'text',
name: 'pkceCodeVerifier',
name: 'pkceCodeChallenge',
label: 'Code Verifier',
placeholder: 'Automatically generated if not provided',
placeholder: 'Automatically generated when not set',
optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
},
@@ -325,8 +327,8 @@ export const plugin: PluginDefinition = {
credentialsInBody,
pkce: values.usePkce
? {
challengeMethod: stringArg(values, 'pkceChallengeMethod'),
codeVerifier: stringArgOrNull(values, 'pkceCodeVerifier'),
challengeMethod: stringArg(values, 'pkceChallengeMethod') || DEFAULT_PKCE_METHOD,
codeVerifier: stringArg(values, 'pkceCodeVerifier') || genPkceCodeVerifier(),
}
: null,
tokenName: tokenName,

292
src-tauri/Cargo.lock generated
View File

@@ -674,6 +674,25 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -926,6 +945,32 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -1080,6 +1125,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -1198,6 +1249,12 @@ dependencies = [
"sha2",
]
[[package]]
name = "deflate64"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]]
name = "deranged"
version = "0.4.0"
@@ -1326,6 +1383,15 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "document-features"
version = "0.2.11"
@@ -1593,6 +1659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
dependencies = [
"crc32fast",
"libz-rs-sys",
"miniz_oxide",
]
@@ -2920,6 +2987,26 @@ dependencies = [
"winapi",
]
[[package]]
name = "liblzma"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0"
dependencies = [
"liblzma-sys",
]
[[package]]
name = "liblzma-sys"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "libredox"
version = "0.1.3"
@@ -2956,6 +3043,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libz-rs-sys"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
dependencies = [
"zlib-rs",
]
[[package]]
name = "libz-sys"
version = "1.1.22"
@@ -3712,6 +3808,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3823,6 +3929,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -4557,9 +4673,9 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.12.19"
version = "0.12.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
dependencies = [
"async-compression",
"base64 0.22.1",
@@ -4577,13 +4693,11 @@ dependencies = [
"hyper-rustls",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -4596,7 +4710,6 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-socks",
"tokio-util",
"tower 0.5.2",
"tower-http",
@@ -4704,6 +4817,17 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust-ini"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f"
dependencies = [
"cfg-if",
"ordered-multimap",
"trim-in-place",
]
[[package]]
name = "rust_decimal"
version = "1.37.1"
@@ -5731,6 +5855,26 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4976ac728ebc0487515aa956cfdf200abcc52b784e441493fc544bc6ce369c8"
dependencies = [
"dunce",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.12",
"tracing",
"url",
"windows-registry",
"windows-result",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.2.2"
@@ -5863,6 +6007,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.12",
"tracing",
"windows-sys 0.59.0",
@@ -5898,7 +6043,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.59.0",
"zip",
"zip 2.4.2",
]
[[package]]
@@ -6137,6 +6282,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -6211,18 +6365,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-socks"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
dependencies = [
"either",
"futures-util",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.17"
@@ -6500,6 +6642,12 @@ dependencies = [
"petgraph",
]
[[package]]
name = "trim-in-place"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc"
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -7644,6 +7792,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-log",
@@ -7678,7 +7827,10 @@ name = "yaak-common"
version = "0.1.0"
dependencies = [
"regex",
"reqwest",
"serde",
"tauri",
"thiserror 2.0.12",
]
[[package]]
@@ -7777,6 +7929,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.12",
"ts-rs",
"yaak-common",
"yaak-models",
]
@@ -7823,16 +7976,21 @@ name = "yaak-plugins"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dunce",
"futures-util",
"hex",
"log",
"md5",
"path-slash",
"rand 0.9.1",
"regex",
"reqwest",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-plugin",
"tauri-plugin-shell",
"thiserror 2.0.12",
"tokio",
@@ -7842,6 +8000,7 @@ dependencies = [
"yaak-crypto",
"yaak-models",
"yaak-templates",
"zip-extract",
]
[[package]]
@@ -8037,6 +8196,20 @@ name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "zerotrie"
@@ -8086,6 +8259,89 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "zip"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.3.3",
"hmac",
"indexmap 2.9.0",
"liblzma",
"memchr",
"pbkdf2",
"sha1",
"time",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zip-extract"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aed5f10c571472911e37d8f7601a8dfba52b4f7f73a344015291b82ab292faf6"
dependencies = [
"log",
"thiserror 2.0.12",
"zip 4.0.0",
]
[[package]]
name = "zlib-rs"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
[[package]]
name = "zopfli"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.15+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.5.3"

View File

@@ -1,9 +1,9 @@
[workspace]
members = [
"yaak-crypto",
"yaak-fonts",
"yaak-git",
"yaak-grpc",
"yaak-fonts",
"yaak-http",
"yaak-license",
"yaak-mac-window",
@@ -40,7 +40,7 @@ tauri-build = { version = "2.2.0", features = [] }
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[dependencies]
chrono = { version = "0.4.31", features = ["serde"] }
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" }
@@ -56,27 +56,28 @@ serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2.2.2"
tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = "2.2.0"
tauri-plugin-log = { version = "2.3.1", features = ["colored"] }
tauri-plugin-fs = "2.3.0"
tauri-plugin-log = { version = "2.4.0", features = ["colored"] }
tauri-plugin-opener = "2.2.6"
tauri-plugin-os = "2.2.1"
tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = "2.2.2"
tauri-plugin-updater = "2.6.1"
tauri-plugin-window-state = "2.2.1"
tauri-plugin-deep-link = "2.3.0"
tauri-plugin-single-instance = { version = "2.2.4", features = ["deep-link"] }
tauri-plugin-updater = "2.7.1"
tauri-plugin-window-state = "2.2.2"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-stream = "0.1.17"
uuid = "1.12.1"
yaak-common = { workspace = true }
yaak-crypto = { workspace = true }
yaak-fonts = { workspace = true }
yaak-git = { path = "yaak-git" }
yaak-grpc = { path = "yaak-grpc" }
yaak-http = { workspace = true }
yaak-license = { path = "yaak-license" }
yaak-mac-window = { path = "yaak-mac-window" }
yaak-models = { workspace = true }
yaak-fonts = { workspace = true }
yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
yaak-sync = { workspace = true }
@@ -84,7 +85,9 @@ yaak-templates = { workspace = true }
yaak-ws = { path = "yaak-ws" }
[workspace.dependencies]
reqwest = "0.12.19"
chrono = "0.4.41"
hex = "0.4.3"
reqwest = "0.12.20"
serde = "1.0.219"
serde_json = "1.0.140"
tauri = "2.5.1"
@@ -96,7 +99,9 @@ thiserror = "2.0.12"
ts-rs = "11.0.1"
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" }
yaak-http = { path = "yaak-http" }
yaak-models = { path = "yaak-models" }
@@ -104,4 +109,3 @@ yaak-plugins = { path = "yaak-plugins" }
yaak-sse = { path = "yaak-sse" }
yaak-sync = { path = "yaak-sync" }
yaak-templates = { path = "yaak-templates" }
yaak-crypto = { path = "yaak-crypto" }

View File

@@ -52,11 +52,12 @@
"opener:allow-reveal-item-in-dir",
"shell:allow-open",
"yaak-crypto:default",
"yaak-git:default",
"yaak-fonts:default",
"yaak-git:default",
"yaak-license:default",
"yaak-mac-window:default",
"yaak-models:default",
"yaak-plugins:default",
"yaak-sync:default",
"yaak-ws:default"
]

View File

@@ -2,7 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Enable for v8 execution -->
<!-- Enable for NodeJS execution -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>

View File

@@ -12,7 +12,7 @@ pub enum Error {
#[error(transparent)]
SyncError(#[from] yaak_sync::error::Error),
#[error(transparent)]
CryptoError(#[from] yaak_crypto::error::Error),
@@ -28,18 +28,21 @@ pub enum Error {
#[error(transparent)]
PluginError(#[from] yaak_plugins::error::Error),
#[error(transparent)]
CommonError(#[from] yaak_common::error::Error),
#[error("Updater error: {0}")]
UpdaterError(#[from] tauri_plugin_updater::Error),
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::error::Error),
#[error("Tauri error: {0}")]
TauriError(#[from] tauri::Error),
#[error("Event source error: {0}")]
EventSourceError(#[from] eventsource_client::Error),
#[error("I/O error: {0}")]
IOError(#[from] io::Error),

View File

@@ -43,18 +43,6 @@ pub async fn store_launch_history<R: Runtime>(app_handle: &AppHandle<R>) -> Laun
info
}
pub fn get_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
pub async fn get_num_launches<R: Runtime>(app_handle: &AppHandle<R>) -> i32 {
app_handle.db().get_key_value_int(NAMESPACE, NUM_LAUNCHES_KEY, 0)
}

105
src-tauri/src/import.rs Normal file
View File

@@ -0,0 +1,105 @@
use crate::error::Result;
use log::info;
use std::collections::BTreeMap;
use std::fs::read_to_string;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_plugins::manager::PluginManager;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>();
let file =
read_to_string(file_path).unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
let file_contents = file.as_str();
let import_result = plugin_manager.import_data(window, file_contents).await?;
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
let resources = import_result.resources;
let workspaces: Vec<Workspace> = resources
.workspaces
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Workspace>(v.id.as_str(), &mut id_map);
v
})
.collect();
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v
})
.collect();
let folders: Vec<Folder> = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(v.folder_id, &mut id_map);
v
})
.collect();
let http_requests: Vec<HttpRequest> = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(v.folder_id, &mut id_map);
v
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(v.folder_id, &mut id_map);
v
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(v.folder_id, &mut id_map);
v
})
.collect();
info!("Importing data");
let upserted = window.with_tx(|tx| {
tx.batch_upsert(
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
&UpdateSource::Import,
)
})?;
Ok(upserted)
}

View File

@@ -3,14 +3,15 @@ use crate::encoding::read_response_body;
use crate::error::Error::GenericError;
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
use crate::http_request::send_http_request;
use crate::import::import_data;
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_uri_scheme;
use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult;
use eventsource_client::{EventParser, SSE};
use log::{debug, error, info, warn};
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;
use std::fs::{File, create_dir_all};
use std::path::PathBuf;
use std::str::FromStr;
@@ -19,31 +20,29 @@ use std::{fs, panic};
use tauri::{AppHandle, Emitter, RunEvent, State, WebviewWindow, is_dev};
use tauri::{Listener, Runtime};
use tauri::{Manager, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::fern::colors::ColoredLevelConfig;
use tauri_plugin_log::{Builder, Target, TargetKind};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::fs::read_to_string;
use tokio::sync::Mutex;
use tokio::task::block_in_place;
use yaak_common::window::WorkspaceWindowTrait;
use yaak_grpc::manager::{DynamicMessage, GrpcHandle};
use yaak_grpc::{Code, ServiceDefinition, deserialize_message, serialize_message};
use yaak_models::models::{
CookieJar, Environment, Folder, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType,
GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, Plugin, WebsocketRequest, Workspace,
WorkspaceMeta,
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType,
GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, Plugin, Workspace, WorkspaceMeta,
};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::{
BatchUpsertResult, UpdateSource, get_workspace_export_resources, maybe_gen_id, maybe_gen_id_opt,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
use yaak_plugins::events::{
BootResponse, CallHttpRequestActionRequest, FilterResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, InternalEvent,
InternalEventPayload, JsonPrimitive, PluginWindowContext, RenderPurpose,
CallHttpRequestActionRequest, FilterResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionsResponse, InternalEvent, InternalEventPayload, JsonPrimitive,
PluginWindowContext, RenderPurpose,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_sse::sse::ServerSentEvent;
use yaak_templates::format::format_json;
@@ -55,6 +54,7 @@ mod error;
mod grpc;
mod history;
mod http_request;
mod import;
mod notifications;
mod plugin_events;
mod render;
@@ -778,98 +778,9 @@ async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>>
#[tauri::command]
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>,
file_path: &str,
) -> YaakResult<BatchUpsertResult> {
let file = read_to_string(file_path)
.await
.unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
let file_contents = file.as_str();
let import_result = plugin_manager.import_data(&window, file_contents).await?;
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
let resources = import_result.resources;
let workspaces: Vec<Workspace> = resources
.workspaces
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Workspace>(v.id.as_str(), &mut id_map);
v
})
.collect();
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v
})
.collect();
let folders: Vec<Folder> = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(v.folder_id, &mut id_map);
v
})
.collect();
let http_requests: Vec<HttpRequest> = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(v.folder_id, &mut id_map);
v
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(v.folder_id, &mut id_map);
v
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(v.folder_id, &mut id_map);
v
})
.collect();
info!("Importing data");
let upserted = app_handle.with_tx(|tx| {
tx.batch_upsert(
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
&UpdateSource::Import,
)
})?;
Ok(upserted)
import_data(&window, file_path).await
}
#[tauri::command]
@@ -1066,7 +977,7 @@ async fn cmd_install_plugin<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<Plugin> {
plugin_manager.add_plugin_by_dir(&PluginWindowContext::new(&window), &directory, true).await?;
plugin_manager.add_plugin_by_dir(&PluginWindowContext::new(&window), &directory).await?;
Ok(app_handle.db().upsert_plugin(
&Plugin {
@@ -1129,14 +1040,13 @@ async fn cmd_plugin_info<R: Runtime>(
id: &str,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<BootResponse> {
) -> YaakResult<PluginMetadata> {
let plugin = app_handle.db().get_plugin(id)?;
Ok(plugin_manager
.get_plugin_by_dir(plugin.directory.as_str())
.await
.ok_or(GenericError("Failed to find plugin for info".to_string()))?
.info()
.await)
.info())
}
#[tauri::command]
@@ -1255,6 +1165,7 @@ pub fn run() {
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
@@ -1272,6 +1183,21 @@ pub fn run() {
builder
.setup(|app| {
{
let app_handle = app.app_handle().clone();
app.deep_link().on_open_url(move |event| {
info!("Handling deep link open");
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
for url in event.urls() {
if let Err(e) = handle_deep_link(&app_handle, &url).await {
warn!("Failed to handle deep link {}: {e:?}", url.to_string());
};
}
});
});
};
let app_data_dir = app.path().app_data_dir().unwrap();
create_dir_all(app_data_dir.clone()).expect("Problem creating App directory!");
@@ -1332,7 +1258,6 @@ pub fn run() {
crate::commands::cmd_secure_template,
crate::commands::cmd_show_workspace_key,
])
.register_uri_scheme_protocol("yaak", handle_uri_scheme)
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app_handle, event| {
@@ -1377,7 +1302,7 @@ pub fn run() {
tokio::time::sleep(Duration::from_millis(4000)).await;
let val: State<'_, Mutex<YaakNotifier>> = w.state();
let mut n = val.lock().await;
if let Err(e) = n.check(&w).await {
if let Err(e) = n.maybe_check(&w).await {
warn!("Failed to check for notifications {}", e)
}
});

View File

@@ -1,13 +1,14 @@
use std::time::SystemTime;
use crate::error::Result;
use crate::history::{get_num_launches, get_os};
use chrono::{DateTime, Duration, Utc};
use crate::history::get_num_launches;
use chrono::{DateTime, Utc};
use log::debug;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
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;
@@ -27,6 +28,7 @@ pub struct YaakNotifier {
#[serde(default, rename_all = "camelCase")]
pub struct YaakNotification {
timestamp: DateTime<Utc>,
timeout: Option<f64>,
id: String,
message: String,
action: Option<YaakNotificationAction>,
@@ -61,7 +63,7 @@ impl YaakNotifier {
Ok(())
}
pub async fn check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<()> {
pub async fn maybe_check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<()> {
let app_handle = window.app_handle();
let ignore_check = self.last_check.elapsed().unwrap().as_secs() < MAX_UPDATE_CHECK_SECONDS;
@@ -80,7 +82,7 @@ impl YaakNotifier {
let settings = window.db().get_settings();
let num_launches = get_num_launches(app_handle).await;
let info = app_handle.package_info().clone();
let req = reqwest::Client::default()
let req = yaak_api_client(app_handle)?
.request(Method::GET, "https://notify.yaak.app/notifications")
.query(&[
("version", info.version.to_string().as_str()),
@@ -95,22 +97,9 @@ impl YaakNotifier {
return Ok(());
}
let result = resp.json::<Value>().await?;
// Support both single and multiple notifications.
// TODO: Remove support for single after April 2025
let notifications = match result {
Value::Array(a) => a
.into_iter()
.map(|a| serde_json::from_value(a).unwrap())
.collect::<Vec<YaakNotification>>(),
a @ _ => vec![serde_json::from_value(a).unwrap()],
};
for notification in notifications {
let age = notification.timestamp.signed_duration_since(Utc::now());
for notification in resp.json::<Vec<YaakNotification>>().await? {
let seen = get_kv(app_handle).await?;
if seen.contains(&notification.id) || (age > Duration::days(2)) {
if seen.contains(&notification.id) {
debug!("Already seen notification {}", notification.id);
continue;
}

View File

@@ -86,8 +86,8 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
environment.as_ref(),
&cb,
)
.await
.expect("Failed to render http request");
.await
.expect("Failed to render http request");
Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {
http_request,
}))
@@ -115,7 +115,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: format!(
"Plugin error from {}: {}",
plugin_handle.name().await,
plugin_handle.info().name,
resp.error
),
color: Some(Color::Danger),
@@ -126,7 +126,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await;
None
}
InternalEventPayload::ReloadResponse(_) => {
InternalEventPayload::ReloadResponse(r) => {
let plugins = app_handle.db().list_plugins().unwrap();
for plugin in plugins {
if plugin.directory != plugin_handle.dir {
@@ -142,7 +142,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
let toast_event = plugin_handle.build_event_to_send(
&window_context,
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: format!("Reloaded plugin {}", plugin_handle.dir),
message: format!("Reloaded plugin {}@{}", r.name, r.version),
icon: Some(Icon::Info),
..Default::default()
}),
@@ -188,7 +188,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
cookie_jar,
&mut tokio::sync::watch::channel(false).1, // No-op cancel channel
)
.await;
.await;
let http_response = match result {
Ok(r) => r,
@@ -257,17 +257,17 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
None
}
InternalEventPayload::SetKeyValueRequest(req) => {
let name = plugin_handle.name().await;
let name = plugin_handle.info().name;
app_handle.db().set_plugin_key_value(&name, &req.key, &req.value);
Some(InternalEventPayload::SetKeyValueResponse(SetKeyValueResponse {}))
}
InternalEventPayload::GetKeyValueRequest(req) => {
let name = plugin_handle.name().await;
let name = plugin_handle.info().name;
let value = app_handle.db().get_plugin_key_value(&name, &req.key).map(|v| v.value);
Some(InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value }))
}
InternalEventPayload::DeleteKeyValueRequest(req) => {
let name = plugin_handle.name().await;
let name = plugin_handle.info().name;
let deleted = app_handle.db().delete_plugin_key_value(&name, &req.key).unwrap();
Some(InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted }))
}

View File

@@ -1,25 +1,66 @@
use crate::error::Result;
use crate::import::import_data;
use log::{info, warn};
use tauri::{Manager, Runtime, UriSchemeContext};
use std::collections::HashMap;
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install;
pub(crate) fn handle_uri_scheme<R: Runtime>(
a: UriSchemeContext<R>,
req: http::Request<Vec<u8>>,
) -> http::Response<Vec<u8>> {
println!("------------- Yaak URI scheme invoked!");
let uri = req.uri();
let window = a
.app_handle()
.get_webview_window(a.webview_label())
.expect("Failed to get webview window for URI scheme event");
info!("Yaak URI scheme invoked with {uri:?} {window:?}");
pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>,
url: &Url,
) -> Result<()> {
let command = url.domain().unwrap_or_default();
info!("Yaak URI scheme invoked {}?{}", command, url.query().unwrap_or_default());
let path = uri.path();
if path == "/data/import" {
warn!("TODO: import data")
} else if path == "/plugins/install" {
warn!("TODO: install plugin")
let query_map: HashMap<String, String> = url.query_pairs().into_owned().collect();
let windows = app_handle.webview_windows();
let (_, window) = windows.iter().next().unwrap();
match command {
"install-plugin" => {
let name = query_map.get("name").unwrap();
let version = query_map.get("version").cloned();
_ = window.set_focus();
let confirmed_install = app_handle
.dialog()
.message(format!("Install plugin {name} {version:?}?",))
.kind(MessageDialogKind::Info)
.buttons(MessageDialogButtons::OkCustom("Install".to_string()))
.blocking_show();
if !confirmed_install {
// Cancelled installation
return Ok(());
}
let pv = download_and_install(window, name, version).await?;
app_handle.emit(
"show_toast",
ShowToastRequest {
message: format!("Installed {name}@{}", pv.version),
color: Some(Color::Success),
icon: None,
},
)?;
}
"import-data" => {
let file_path = query_map.get("path").unwrap();
let results = import_data(window, file_path).await?;
_ = window.set_focus();
window.emit(
"show_toast",
ShowToastRequest {
message: format!("Imported data for {} workspaces", results.workspaces.len()),
color: Some(Color::Success),
icon: None,
},
)?;
}
_ => {
warn!("Unknown deep link command: {command}");
}
}
let msg = format!("No handler found for {path}");
tauri::http::Response::builder().status(404).body(msg.as_bytes().to_vec()).unwrap()
Ok(())
}

View File

@@ -23,7 +23,6 @@
},
"plugins": {
"deep-link": {
"mobile": [],
"desktop": {
"schemes": [
"yaak"

View File

@@ -6,4 +6,7 @@ publish = false
[dependencies]
tauri = { workspace = true }
reqwest = { workspace = true, features = ["system-proxy", "gzip"] }
thiserror = { workspace = true }
regex = "1.11.0"
serde = { workspace = true, features = ["derive"] }

View File

@@ -0,0 +1,24 @@
use crate::error::Result;
use crate::platform::{get_ua_arch, get_ua_platform};
use reqwest::Client;
use std::time::Duration;
use tauri::http::{HeaderMap, HeaderValue};
use tauri::{AppHandle, Runtime};
pub fn yaak_api_client<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Client> {
let platform = get_ua_platform();
let version = app_handle.package_info().version.clone();
let arch = get_ua_arch();
let ua = format!("Yaak/{version} ({platform}; {arch})");
let mut default_headers = HeaderMap::new();
default_headers.insert("Accept", HeaderValue::from_str("application/json").unwrap());
let client = reqwest::ClientBuilder::new()
.timeout(Duration::from_secs(20))
.default_headers(default_headers)
.gzip(true)
.user_agent(ua)
.build()?;
Ok(client)
}

View File

@@ -0,0 +1,19 @@
use serde::{Serialize, Serializer};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
ReqwestError(#[from] reqwest::Error),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -1 +1,4 @@
pub mod window;
pub mod window;
pub mod platform;
pub mod api_client;
pub mod error;

View File

@@ -0,0 +1,36 @@
pub fn get_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
pub fn get_ua_platform() -> &'static str {
if cfg!(target_os = "windows") {
"Win"
} else if cfg!(target_os = "macos") {
"Mac"
} else if cfg!(target_os = "linux") {
"Linux"
} else {
"Unknown"
}
}
pub fn get_ua_arch() -> &'static str {
if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "x86") {
"i386"
} else if cfg!(target_arch = "arm") {
"ARM"
} else if cfg!(target_arch = "aarch64") {
"ARM64"
} else {
"Unknown"
}}

View File

@@ -13,7 +13,7 @@ keyring = { version = "4.0.0-rc.1" }
log = "0.4.26"
serde = { workspace = true, features = ["derive"] }
tauri = { workspace = true }
thiserror = "2.0.12"
thiserror = { workspace = true }
yaak-models = { workspace = true }
[build-dependencies]

View File

@@ -6,7 +6,7 @@ edition = "2024"
publish = false
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
chrono = { workspace = true, features = ["serde"] }
git2 = { version = "0.20.0", features = ["vendored-libgit2", "vendored-openssl"] }
log = "0.4.22"
serde = { workspace = true, features = ["derive"] }

View File

@@ -15,7 +15,7 @@ pub enum Error {
#[error("Yaml error: {0}")]
YamlParseError(#[from] serde_yaml::Error),
#[error("Yaml error: {0}")]
#[error(transparent)]
ModelError(#[from] yaak_models::error::Error),
#[error("Sync error: {0}")]
@@ -24,10 +24,10 @@ pub enum Error {
#[error("I/o error: {0}")]
IoError(#[from] io::Error),
#[error("Yaml error: {0}")]
#[error("JSON error: {0}")]
JsonParseError(#[from] serde_json::Error),
#[error("Yaml error: {0}")]
#[error("UTF8 error: {0}")]
Utf8ConversionError(#[from] FromUtf8Error),
#[error("Git error: {0}")]

View File

@@ -15,6 +15,7 @@ tauri = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
yaak-models = { workspace = true }
yaak-common = { workspace = true }
[build-dependencies]
tauri-plugin = { workspace = true, features = ["build"] }

View File

@@ -15,6 +15,9 @@ pub enum Error {
#[error(transparent)]
ModelError(#[from] yaak_models::error::Error),
#[error(transparent)]
CommonError(#[from] yaak_common::error::Error),
#[error("Internal server error")]
ServerError,
}

View File

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

View File

@@ -7,6 +7,8 @@ use std::ops::Add;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use ts_rs::TS;
use yaak_common::api_client::yaak_api_client;
use yaak_common::platform::get_os;
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
@@ -68,7 +70,7 @@ pub async fn activate_license<R: Runtime>(
let client = reqwest::Client::new();
let payload = ActivateLicenseRequestPayload {
license_key: license_key.to_string(),
app_platform: crate::get_os().to_string(),
app_platform: get_os().to_string(),
app_version: window.app_handle().package_info().version.to_string(),
};
let response = client.post(build_url("/licenses/activate")).json(&payload).send().await?;
@@ -107,7 +109,7 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
let client = reqwest::Client::new();
let path = format!("/licenses/activations/{}/deactivate", activation_id);
let payload = DeactivateLicenseRequestPayload {
app_platform: crate::get_os().to_string(),
app_platform: get_os().to_string(),
app_version: window.app_handle().package_info().version.to_string(),
};
let response = client.post(build_url(&path)).json(&payload).send().await?;
@@ -149,7 +151,7 @@ pub enum LicenseCheckStatus {
pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<LicenseCheckStatus> {
let payload = CheckActivationRequestPayload {
app_platform: crate::get_os().to_string(),
app_platform: get_os().to_string(),
app_version: window.package_info().version.to_string(),
};
let activation_id = get_activation_id(window.app_handle()).await;
@@ -169,7 +171,7 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
(true, _) => {
info!("Checking license activation");
// A license has been activated, so let's check the license server
let client = reqwest::Client::new();
let client = yaak_api_client(window.app_handle())?;
let path = format!("/licenses/activations/{activation_id}/check");
let response = client.post(build_url(&path)).json(&payload).send().await?;

View File

@@ -7,7 +7,7 @@ publish = false
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
hex = "0.4.3"
hex = { workspace = true }
include_dir = "0.7"
log = "0.4.22"
nanoid = "0.4.0"
@@ -18,10 +18,10 @@ sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = "0.10.9"
tauri = { workspace = true}
sha2 = { workspace = true }
tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true }
thiserror = "2.0.11"
thiserror = { workspace = true }
tokio = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }

View File

@@ -58,7 +58,7 @@ export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
export type ProxySetting = { "type": "enabled", disabled: boolean, http: string, https: string, auth: ProxySettingAuth | null, bypass: string, } | { "type": "disabled" };
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
export type ProxySettingAuth = { user: string, password: string, };

View File

@@ -1,14 +1,15 @@
use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Error::RowNotFound;
use crate::error::Error::DBRowNotFound;
use crate::models::{AnyModel, UpsertModelInfo};
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
use log::error;
use rusqlite::OptionalExtension;
use sea_query::{
Asterisk, Expr, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, SimpleExpr,
SqliteQueryBuilder,
};
use sea_query_rusqlite::RusqliteBinder;
use tokio::sync::mpsc;
use std::sync::mpsc;
pub struct DbContext<'a> {
pub(crate) events_tx: mpsc::Sender<ModelPayload>,
@@ -26,7 +27,7 @@ impl<'a> DbContext<'a> {
{
match self.find_optional::<M>(col, value) {
Some(v) => Ok(v),
None => Err(RowNotFound),
None => Err(DBRowNotFound(format!("{:?}", M::table_name()))),
}
}
@@ -150,16 +151,15 @@ impl<'a> DbContext<'a> {
update_source: source.clone(),
change: ModelChangeEvent::Upsert,
};
self.events_tx.try_send(payload).unwrap();
if let Err(e) = self.events_tx.send(payload.clone()) {
error!("Failed to send model change {source:?}: {e:?}");
}
Ok(m)
}
pub(crate) fn delete<'s, M>(
&self,
m: &M,
update_source: &UpdateSource,
) -> crate::error::Result<M>
pub(crate) fn delete<'s, M>(&self, m: &M, source: &UpdateSource) -> crate::error::Result<M>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
@@ -171,11 +171,13 @@ impl<'a> DbContext<'a> {
let payload = ModelPayload {
model: m.clone().into(),
update_source: update_source.clone(),
update_source: source.clone(),
change: ModelChangeEvent::Delete,
};
self.events_tx.try_send(payload).unwrap();
if let Err(e) = self.events_tx.send(payload) {
error!("Failed to send model change {source:?}: {e:?}");
}
Ok(m.clone())
}
}

View File

@@ -30,8 +30,8 @@ pub enum Error {
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
MultipleBaseEnvironments(String),
#[error("Row not found")]
RowNotFound,
#[error("Database row not found: {0}")]
DBRowNotFound(String),
#[error("unknown error")]
Unknown,

View File

@@ -6,12 +6,12 @@ use log::error;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::fs::create_dir_all;
use std::sync::mpsc;
use std::time::Duration;
use tauri::async_runtime::Mutex;
use tauri::plugin::TauriPlugin;
use tauri::{Emitter, Manager, Runtime, generate_handler};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tokio::sync::mpsc;
mod commands;
@@ -72,11 +72,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
app_handle.manage(SqliteConnection::new(pool.clone()));
{
let (tx, mut rx) = mpsc::channel(128);
let (tx, rx) = mpsc::channel();
app_handle.manage(QueryManager::new(pool, tx));
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
while let Some(p) = rx.recv().await {
for p in rx {
let name = match p.change {
ModelChangeEvent::Upsert => "upserted_model",
ModelChangeEvent::Delete => "deleted_model",

View File

@@ -11,7 +11,7 @@ use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_d
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fmt::Display;
use std::fmt::{Debug, Display};
use std::str::FromStr;
use ts_rs::TS;
@@ -123,7 +123,7 @@ pub struct Settings {
}
impl UpsertModelInfo for Settings {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
SettingsIden::Table
}
@@ -252,7 +252,7 @@ pub struct Workspace {
}
impl UpsertModelInfo for Workspace {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
WorkspaceIden::Table
}
@@ -355,7 +355,7 @@ pub struct WorkspaceMeta {
}
impl UpsertModelInfo for WorkspaceMeta {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
WorkspaceMetaIden::Table
}
@@ -456,7 +456,7 @@ pub struct CookieJar {
}
impl UpsertModelInfo for CookieJar {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
CookieJarIden::Table
}
@@ -535,7 +535,7 @@ pub struct Environment {
}
impl UpsertModelInfo for Environment {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
EnvironmentIden::Table
}
@@ -655,7 +655,7 @@ pub struct Folder {
}
impl UpsertModelInfo for Folder {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
FolderIden::Table
}
@@ -786,7 +786,7 @@ pub struct HttpRequest {
}
impl UpsertModelInfo for HttpRequest {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
HttpRequestIden::Table
}
@@ -913,7 +913,7 @@ pub struct WebsocketConnection {
}
impl UpsertModelInfo for WebsocketConnection {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
WebsocketConnectionIden::Table
}
@@ -1027,7 +1027,7 @@ pub struct WebsocketRequest {
}
impl UpsertModelInfo for WebsocketRequest {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
WebsocketRequestIden::Table
}
@@ -1152,7 +1152,7 @@ pub struct WebsocketEvent {
}
impl UpsertModelInfo for WebsocketEvent {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
WebsocketEventIden::Table
}
@@ -1269,7 +1269,7 @@ pub struct HttpResponse {
}
impl UpsertModelInfo for HttpResponse {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
HttpResponseIden::Table
}
@@ -1377,7 +1377,7 @@ pub struct GraphQlIntrospection {
}
impl UpsertModelInfo for GraphQlIntrospection {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
GraphQlIntrospectionIden::Table
}
@@ -1461,7 +1461,7 @@ pub struct GrpcRequest {
}
impl UpsertModelInfo for GrpcRequest {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
GrpcRequestIden::Table
}
@@ -1588,7 +1588,7 @@ pub struct GrpcConnection {
}
impl UpsertModelInfo for GrpcConnection {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
GrpcConnectionIden::Table
}
@@ -1708,7 +1708,7 @@ pub struct GrpcEvent {
}
impl UpsertModelInfo for GrpcEvent {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
GrpcEventIden::Table
}
@@ -1799,7 +1799,7 @@ pub struct Plugin {
}
impl UpsertModelInfo for Plugin {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
PluginIden::Table
}
@@ -1881,7 +1881,7 @@ pub struct SyncState {
}
impl UpsertModelInfo for SyncState {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
SyncStateIden::Table
}
@@ -1964,7 +1964,7 @@ pub struct KeyValue {
}
impl UpsertModelInfo for KeyValue {
fn table_name() -> impl IntoTableRef {
fn table_name() -> impl IntoTableRef + Debug {
KeyValueIden::Table
}
@@ -2181,7 +2181,7 @@ impl AnyModel {
}
pub trait UpsertModelInfo {
fn table_name() -> impl IntoTableRef;
fn table_name() -> impl IntoTableRef + Debug;
fn id_column() -> impl IntoIden + Eq + Clone;
fn generate_id() -> String;
fn order_by() -> (impl IntoColumnRef, Order);

View File

@@ -22,7 +22,7 @@ impl<'a> DbContext<'a> {
let x = self.upsert_workspace(&v, source)?;
imported_resources.workspaces.push(x.clone());
}
info!("Upserted {} workspaces", imported_resources.environments.len());
info!("Upserted {} workspaces", imported_resources.workspaces.len());
}
if http_requests.len() > 0 {

View File

@@ -6,9 +6,8 @@ use crate::util::ModelPayload;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::TransactionBehavior;
use std::sync::{Arc, Mutex};
use std::sync::{mpsc, Arc, Mutex};
use tauri::{Manager, Runtime, State};
use tokio::sync::mpsc;
pub trait QueryManagerExt<'a, R> {
fn db_manager(&'a self) -> State<'a, QueryManager>;

View File

@@ -1,10 +1,12 @@
[package]
name = "yaak-plugins"
links = "yaak-plugins"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
base64 = "0.22.1"
dunce = "1.0.4"
futures-util = "0.3.30"
log = "0.4.21"
@@ -12,16 +14,23 @@ md5 = "0.7.0"
path-slash = "0.2.1"
rand = "0.9.0"
regex = "1.10.6"
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tauri = { workspace = true }
tauri-plugin-shell = { workspace = true }
thiserror = "2.0.7"
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 }
yaak-crypto = { workspace = true }
yaak-common = { workspace = true }
base64 = "0.22.1"
zip-extract = "0.4.0"
chrono = { workspace = true }
hex = { workspace = true }
[build-dependencies]
tauri-plugin = { workspace = true, features = ["build"] }

View File

@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginVersion } from "./gen_search.js";
export type PluginNameVersion = { name: string, version: string, };
export type PluginSearchResponse = { plugins: Array<PluginVersion>, };
export type PluginUpdatesResponse = { plugins: Array<PluginNameVersion>, };

View File

@@ -372,7 +372,7 @@ export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, windowContext: PluginWindowContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & EmptyPayload | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & BootResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type JsonPrimitive = string | number | boolean | null;

View File

@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };
export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };

View File

@@ -0,0 +1,5 @@
const COMMANDS: &[&str] = &["search", "install", "updates"];
fn main() {
tauri_plugin::Builder::new(COMMANDS).build();
}

View File

@@ -1,2 +1,18 @@
import { invoke } from '@tauri-apps/api/core';
import { PluginSearchResponse, PluginUpdatesResponse } from './bindings/gen_api';
export * from './bindings/gen_models';
export * from './bindings/gen_events';
export * from './bindings/gen_search';
export async function searchPlugins(query: string) {
return invoke<PluginSearchResponse>('plugin:yaak-plugins|search', { query });
}
export async function installPlugin(name: string, version: string | null) {
return invoke<string>('plugin:yaak-plugins|install', { name, version });
}
export async function checkPluginUpdates() {
return invoke<PluginUpdatesResponse>('plugin:yaak-plugins|updates', {});
}

View File

@@ -0,0 +1,3 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-search", "allow-install", "allow-updates"]

View File

@@ -0,0 +1,142 @@
use crate::error::Error::ApiErr;
use crate::error::Result;
use crate::plugin_meta::get_plugin_meta;
use log::{info, warn};
use reqwest::{Response, Url};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::str::FromStr;
use tauri::{AppHandle, Runtime, is_dev};
use ts_rs::TS;
use yaak_common::api_client::yaak_api_client;
use yaak_models::query_manager::QueryManagerExt;
pub async fn get_plugin<R: Runtime>(
app_handle: &AppHandle<R>,
name: &str,
version: Option<String>,
) -> Result<PluginVersion> {
info!("Getting plugin: {name} {version:?}");
let mut url = build_url(&format!("/{name}"));
if let Some(version) = version {
let mut query_pairs = url.query_pairs_mut();
query_pairs.append_pair("version", &version);
};
let resp = yaak_api_client(app_handle)?.get(url.clone()).send().await?;
if !resp.status().is_success() {
return Err(ApiErr(format!("{} response to {}", resp.status(), url.to_string())));
}
Ok(resp.json().await?)
}
pub async fn download_plugin_archive<R: Runtime>(
app_handle: &AppHandle<R>,
plugin_version: &PluginVersion,
) -> Result<Response> {
let name = plugin_version.name.clone();
let version = plugin_version.version.clone();
info!("Downloading plugin: {name} {version}");
let mut url = build_url(&format!("/{}/download", name));
{
let mut query_pairs = url.query_pairs_mut();
query_pairs.append_pair("version", &version);
};
let resp = yaak_api_client(app_handle)?.get(url.clone()).send().await?;
if !resp.status().is_success() {
return Err(ApiErr(format!("{} response to {}", resp.status(), url.to_string())));
}
Ok(resp)
}
pub async fn check_plugin_updates<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<PluginUpdatesResponse> {
let name_versions: Vec<PluginNameVersion> = app_handle
.db()
.list_plugins()?
.into_iter()
.filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) {
Ok(m) => Some(PluginNameVersion {
name: m.name,
version: m.version,
}),
Err(e) => {
warn!("Failed to get plugin metadata: {}", e);
None
}
})
.collect();
let url = build_url("/updates");
let body = serde_json::to_vec(&PluginUpdatesResponse {
plugins: name_versions,
})?;
let resp = yaak_api_client(app_handle)?.post(url.clone()).body(body).send().await?;
if !resp.status().is_success() {
return Err(ApiErr(format!("{} response to {}", resp.status(), url.to_string())));
}
let results: PluginUpdatesResponse = resp.json().await?;
Ok(results)
}
pub async fn search_plugins<R: Runtime>(
app_handle: &AppHandle<R>,
query: &str,
) -> Result<PluginSearchResponse> {
let mut url = build_url("/search");
{
let mut query_pairs = url.query_pairs_mut();
query_pairs.append_pair("query", query);
};
let resp = yaak_api_client(app_handle)?.get(url).send().await?;
Ok(resp.json().await?)
}
fn build_url(path: &str) -> Url {
let base_url = if is_dev() {
"http://localhost:9444/api/v1/plugins"
} else {
"https://api.yaak.app/api/v1/plugins"
};
Url::from_str(&format!("{base_url}{path}")).unwrap()
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_search.ts")]
pub struct PluginVersion {
pub id: String,
pub version: String,
pub url: String,
pub description: Option<String>,
pub name: String,
pub display_name: String,
pub homepage_url: Option<String>,
pub repository_url: Option<String>,
pub checksum: String,
pub readme: Option<String>,
pub yanked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_api.ts")]
pub struct PluginSearchResponse {
pub plugins: Vec<PluginVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_api.ts")]
pub struct PluginNameVersion {
name: String,
version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_api.ts")]
pub struct PluginUpdatesResponse {
pub plugins: Vec<PluginNameVersion>,
}

View File

@@ -0,0 +1,8 @@
use sha2::{Digest, Sha256};
pub(crate) fn compute_checksum(bytes: impl AsRef<[u8]>) -> String {
let mut hasher = Sha256::new();
hasher.update(&bytes);
let hash = hasher.finalize();
hex::encode(hash)
}

View File

@@ -0,0 +1,29 @@
use crate::api::{
PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates, search_plugins,
};
use crate::error::Result;
use crate::install::download_and_install;
use tauri::{AppHandle, Runtime, WebviewWindow, command};
#[command]
pub(crate) async fn search<R: Runtime>(
app_handle: AppHandle<R>,
query: &str,
) -> Result<PluginSearchResponse> {
search_plugins(&app_handle, query).await
}
#[command]
pub(crate) async fn install<R: Runtime>(
window: WebviewWindow<R>,
name: &str,
version: Option<String>,
) -> Result<()> {
download_and_install(&window, name, version).await?;
Ok(())
}
#[command]
pub(crate) async fn updates<R: Runtime>(app_handle: AppHandle<R>) -> Result<PluginUpdatesResponse> {
check_plugin_updates(&app_handle).await
}

View File

@@ -1,4 +1,5 @@
use crate::events::InternalEvent;
use serde::{Serialize, Serializer};
use thiserror::Error;
use tokio::io;
use tokio::sync::mpsc::error::SendError;
@@ -8,9 +9,12 @@ pub enum Error {
#[error(transparent)]
CryptoErr(#[from] yaak_crypto::error::Error),
#[error(transparent)]
DbErr(#[from] yaak_models::error::Error),
#[error(transparent)]
TemplateErr(#[from] yaak_templates::error::Error),
#[error("IO error: {0}")]
IoErr(#[from] io::Error),
@@ -23,8 +27,17 @@ pub enum Error {
#[error("Grpc send error: {0}")]
GrpcSendErr(#[from] SendError<InternalEvent>),
#[error("Failed to send request: {0}")]
RequestError(#[from] reqwest::Error),
#[error("JSON error: {0}")]
JsonErr(#[from] serde_json::Error),
#[error("API Error: {0}")]
ApiErr(String),
#[error(transparent)]
CommonError(#[from] yaak_common::error::Error),
#[error("Timeout elapsed: {0}")]
TimeoutElapsed(#[from] tokio::time::error::Elapsed),
@@ -38,6 +51,9 @@ pub enum Error {
#[error("Plugin error: {0}")]
PluginErr(String),
#[error("zip error: {0}")]
ZipError(#[from] zip_extract::ZipExtractError),
#[error("Client not initialized error")]
ClientNotInitializedErr,
@@ -45,4 +61,13 @@ pub enum Error {
UnknownEventErr,
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -67,7 +67,7 @@ pub enum InternalEventPayload {
BootResponse(BootResponse),
ReloadRequest(EmptyPayload),
ReloadResponse(EmptyPayload),
ReloadResponse(BootResponse),
TerminateRequest,
TerminateResponse,

View File

@@ -0,0 +1,64 @@
use crate::api::{PluginVersion, download_plugin_archive, get_plugin};
use crate::checksum::compute_checksum;
use crate::error::Error::PluginErr;
use crate::error::Result;
use crate::events::PluginWindowContext;
use crate::manager::PluginManager;
use chrono::Utc;
use log::info;
use std::fs::{create_dir_all, remove_dir_all};
use std::io::Cursor;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak_models::models::Plugin;
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::UpdateSource;
pub async fn download_and_install<R: Runtime>(
window: &WebviewWindow<R>,
name: &str,
version: Option<String>,
) -> Result<PluginVersion> {
let plugin_manager = window.state::<PluginManager>();
let plugin_version = get_plugin(window.app_handle(), name, version).await?;
let resp = download_plugin_archive(window.app_handle(), &plugin_version).await?;
let bytes = resp.bytes().await?;
let checksum = compute_checksum(&bytes);
if checksum != plugin_version.checksum {
return Err(PluginErr(format!(
"Checksum mismatch {}b {checksum} != {}",
bytes.len(),
plugin_version.checksum
)));
}
info!("Checksum matched {}", checksum);
let plugin_dir = plugin_manager.installed_plugin_dir.join(name);
let plugin_dir_str = plugin_dir.to_str().unwrap().to_string();
// Re-create the plugin directory
let _ = remove_dir_all(&plugin_dir);
create_dir_all(&plugin_dir)?;
zip_extract::extract(Cursor::new(&bytes), &plugin_dir, true)?;
info!("Extracted plugin {} to {}", plugin_version.id, plugin_dir_str);
plugin_manager.add_plugin_by_dir(&PluginWindowContext::new(&window), &plugin_dir_str).await?;
window.db().upsert_plugin(
&Plugin {
id: plugin_version.id.clone(),
checked_at: Some(Utc::now().naive_utc()),
directory: plugin_dir_str.clone(),
enabled: true,
url: Some(plugin_version.url.clone()),
..Default::default()
},
&UpdateSource::Background,
)?;
info!("Installed plugin {} to {}", plugin_version.id, plugin_dir_str);
Ok(plugin_version)
}

View File

@@ -1,9 +1,11 @@
use crate::commands::{install, search, updates};
use crate::manager::PluginManager;
use log::info;
use std::process::exit;
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{Manager, RunEvent, Runtime, State};
use tauri::{Manager, RunEvent, Runtime, State, generate_handler};
mod commands;
pub mod error;
pub mod events;
pub mod manager;
@@ -13,9 +15,14 @@ pub mod plugin_handle;
mod server_ws;
pub mod template_callback;
mod util;
mod checksum;
pub mod api;
pub mod install;
pub mod plugin_meta;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-plugins")
.invoke_handler(generate_handler![search, install, updates])
.setup(|app_handle, _| {
let manager = PluginManager::new(app_handle.clone());
app_handle.manage(manager.clone());

View File

@@ -18,7 +18,7 @@ use crate::server_ws::PluginRuntimeServerWebsocket;
use log::{error, info, warn};
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tauri::path::BaseDirectory;
@@ -38,12 +38,13 @@ pub struct PluginManager {
plugins: Arc<Mutex<Vec<PluginHandle>>>,
kill_tx: tokio::sync::watch::Sender<bool>,
ws_service: Arc<PluginRuntimeServerWebsocket>,
vendored_plugin_dir: PathBuf,
pub(crate) installed_plugin_dir: PathBuf,
}
#[derive(Clone)]
struct PluginCandidate {
dir: String,
watch: bool,
}
impl PluginManager {
@@ -56,11 +57,24 @@ impl PluginManager {
let ws_service =
PluginRuntimeServerWebsocket::new(events_tx, client_disconnect_tx, client_connect_tx);
let vendored_plugin_dir = app_handle
.path()
.resolve("vendored/plugins", BaseDirectory::Resource)
.expect("failed to resolve plugin directory resource");
let installed_plugin_dir = app_handle
.path()
.app_data_dir()
.expect("failed to get app data dir")
.join("installed-plugins");
let plugin_manager = PluginManager {
plugins: Default::default(),
subscribers: Default::default(),
ws_service: Arc::new(ws_service.clone()),
kill_tx: kill_server_tx,
vendored_plugin_dir,
installed_plugin_dir,
};
// Forward events to subscribers
@@ -135,18 +149,13 @@ impl PluginManager {
&self,
app_handle: &AppHandle<R>,
) -> Vec<PluginCandidate> {
let bundled_plugins_dir = &app_handle
.path()
.resolve("vendored/plugins", BaseDirectory::Resource)
.expect("failed to resolve plugin directory resource");
let plugins_dir = if is_dev() {
// Use plugins directly for easy development
env::current_dir()
.map(|cwd| cwd.join("../plugins").canonicalize().unwrap())
.unwrap_or_else(|_| bundled_plugins_dir.clone())
.unwrap_or_else(|_| self.vendored_plugin_dir.to_path_buf())
} else {
bundled_plugins_dir.clone()
self.vendored_plugin_dir.to_path_buf()
};
info!("Loading bundled plugins from {plugins_dir:?}");
@@ -155,13 +164,7 @@ impl PluginManager {
.await
.expect(format!("Failed to read plugins dir: {:?}", plugins_dir).as_str())
.iter()
.map(|d| {
let is_vendored = plugins_dir.starts_with(bundled_plugins_dir);
PluginCandidate {
dir: d.into(),
watch: !is_vendored,
}
})
.map(|d| PluginCandidate { dir: d.into() })
.collect();
let plugins = app_handle.db().list_plugins().unwrap_or_default();
@@ -169,7 +172,6 @@ impl PluginManager {
.iter()
.map(|p| PluginCandidate {
dir: p.directory.to_owned(),
watch: true,
})
.collect();
@@ -203,7 +205,6 @@ impl PluginManager {
&self,
window_context: &PluginWindowContext,
dir: &str,
watch: bool,
) -> Result<()> {
info!("Adding plugin by dir {dir}");
let maybe_tx = self.ws_service.app_to_plugin_events_tx.lock().await;
@@ -211,7 +212,10 @@ impl PluginManager {
None => return Err(ClientNotInitializedErr),
Some(tx) => tx,
};
let plugin_handle = PluginHandle::new(dir, tx.clone());
let plugin_handle = PluginHandle::new(dir, tx.clone())?;
let dir_path = Path::new(dir);
let is_vendored = dir_path.starts_with(self.vendored_plugin_dir.as_path());
let is_installed = dir_path.starts_with(self.installed_plugin_dir.as_path());
// Boot the plugin
let event = timeout(
@@ -221,7 +225,7 @@ impl PluginManager {
&plugin_handle,
&InternalEventPayload::BootRequest(BootRequest {
dir: dir.to_string(),
watch,
watch: !is_vendored && !is_installed,
}),
),
)
@@ -230,14 +234,11 @@ impl PluginManager {
// Add the new plugin
self.plugins.lock().await.push(plugin_handle.clone());
let resp = match event.payload {
let _ = match event.payload {
InternalEventPayload::BootResponse(resp) => resp,
_ => return Err(UnknownEventErr),
};
// Set the boot response
plugin_handle.set_boot_response(&resp).await;
Ok(())
}
@@ -256,10 +257,7 @@ impl PluginManager {
continue;
}
}
if let Err(e) = self
.add_plugin_by_dir(window_context, candidate.dir.as_str(), candidate.watch)
.await
{
if let Err(e) = self.add_plugin_by_dir(window_context, candidate.dir.as_str()).await {
warn!("Failed to add plugin {} {e:?}", candidate.dir);
}
}
@@ -319,7 +317,7 @@ impl PluginManager {
pub async fn get_plugin_by_name(&self, name: &str) -> Option<PluginHandle> {
for plugin in self.plugins.lock().await.iter().cloned() {
let info = plugin.info().await;
let info = plugin.info();
if info.name == name {
return Some(plugin);
}

View File

@@ -1,38 +1,35 @@
use crate::error::Result;
use crate::events::{BootResponse, InternalEvent, InternalEventPayload, PluginWindowContext};
use crate::events::{InternalEvent, InternalEventPayload, PluginWindowContext};
use crate::plugin_meta::{PluginMetadata, get_plugin_meta};
use crate::util::gen_id;
use log::info;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tokio::sync::{Mutex, mpsc};
#[derive(Clone)]
pub struct PluginHandle {
pub ref_id: String,
pub dir: String,
pub(crate) to_plugin_tx: Arc<Mutex<mpsc::Sender<InternalEvent>>>,
pub(crate) boot_resp: Arc<Mutex<BootResponse>>,
pub(crate) metadata: PluginMetadata,
}
impl PluginHandle {
pub fn new(dir: &str, tx: mpsc::Sender<InternalEvent>) -> Self {
pub fn new(dir: &str, tx: mpsc::Sender<InternalEvent>) -> Result<Self> {
let ref_id = gen_id();
let metadata = get_plugin_meta(&Path::new(dir))?;
PluginHandle {
Ok(PluginHandle {
ref_id: ref_id.clone(),
dir: dir.to_string(),
to_plugin_tx: Arc::new(Mutex::new(tx)),
boot_resp: Arc::new(Mutex::new(BootResponse::default())),
}
metadata,
})
}
pub async fn name(&self) -> String {
self.boot_resp.lock().await.name.clone()
}
pub async fn info(&self) -> BootResponse {
let resp = &*self.boot_resp.lock().await;
resp.clone()
pub fn info(&self) -> PluginMetadata {
self.metadata.clone()
}
pub fn build_event_to_send(
@@ -72,9 +69,4 @@ impl PluginHandle {
self.to_plugin_tx.lock().await.send(event.to_owned()).await?;
Ok(())
}
pub async fn set_boot_response(&self, resp: &BootResponse) {
let mut boot_resp = self.boot_resp.lock().await;
*boot_resp = resp.clone();
}
}

View File

@@ -0,0 +1,64 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_search.ts")]
pub struct PluginMetadata {
pub version: String,
pub name: String,
pub display_name: String,
pub description: Option<String>,
pub homepage_url: Option<String>,
pub repository_url: Option<String>,
}
pub(crate) fn get_plugin_meta(plugin_dir: &Path) -> Result<PluginMetadata> {
let package_json = fs::File::open(plugin_dir.join("package.json"))?;
let package_json: PackageJson = serde_json::from_reader(package_json)?;
let display_name = match package_json.display_name {
None => {
let display_name = package_json.name.to_string();
let display_name = display_name.split('/').last().unwrap_or(&package_json.name);
let display_name = display_name.strip_prefix("yaak-plugin-").unwrap_or(&display_name);
let display_name = display_name.strip_prefix("yaak-").unwrap_or(&display_name);
display_name.to_string()
}
Some(n) => n,
};
Ok(PluginMetadata {
version: package_json.version,
description: package_json.description,
name: package_json.name,
display_name,
homepage_url: package_json.homepage,
repository_url: match package_json.repository {
None => None,
Some(RepositoryField::Object { url }) => Some(url),
Some(RepositoryField::String(url)) => Some(url),
},
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PackageJson {
pub name: String,
pub display_name: Option<String>,
pub version: String,
pub repository: Option<RepositoryField>,
pub homepage: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RepositoryField {
String(String),
Object { url: String },
}

View File

@@ -6,8 +6,8 @@ edition = "2024"
publish = false
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
hex = "0.4.3"
chrono = { workspace = true, features = ["serde"] }
hex = { workspace = true }
log = "0.4.22"
notify = "8.0.0"
serde = { workspace = true, features = ["derive"] }

View File

@@ -6,8 +6,11 @@ use thiserror::Error;
pub enum Error {
#[error("Yaml error: {0}")]
YamlParseError(#[from] serde_yaml::Error),
#[error("Sync parse error: {0}")]
ParseError(String),
#[error("Yaml error: {0}")]
#[error(transparent)]
ModelError(#[from] yaak_models::error::Error),
#[error("Unknown model: {0}")]
@@ -16,7 +19,7 @@ pub enum Error {
#[error("I/o error: {0}")]
IoError(#[from] io::Error),
#[error("Yaml error: {0}")]
#[error("JSON error: {0}")]
JsonParseError(#[from] serde_json::Error),
#[error("Invalid sync file: {0}")]

View File

@@ -1,6 +1,7 @@
use crate::error::Error::UnknownModel;
use crate::error::Result;
use chrono::NaiveDateTime;
use log::warn;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
use std::fs;
@@ -37,9 +38,21 @@ impl SyncModel {
let ext = file_path.extension().unwrap_or_default();
if ext == "yml" || ext == "yaml" {
Ok(Some((serde_yaml::from_str(&content_str)?, checksum)))
Ok(match serde_yaml::from_str::<SyncModel>(&content_str) {
Ok(m) => Some((m, checksum)),
Err(e) => {
warn!("Error parsing {:?} {:?}", file_path.file_name(), e);
None
}
})
} else if ext == "json" {
Ok(Some((serde_json::from_str(&content_str)?, checksum)))
Ok(match serde_json::from_str::<SyncModel>(&content_str) {
Ok(m) => Some((m, checksum)),
Err(e) => {
warn!("Error parsing {:?} {:?}", file_path.file_name(), e);
None
}
})
} else {
Ok(None)
}

View File

@@ -12,7 +12,7 @@ md5 = "0.7.0"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tauri = { workspace = true }
thiserror = "2.0.11"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "time", "test-util"] }
tokio-tungstenite = { version = "0.26.2", default-features = false, features = ["rustls-tls-native-roots", "connect"] }
yaak-http = { workspace = true }

View File

@@ -0,0 +1,5 @@
import { atom } from "jotai";
import type { GraphQLSchema } from "graphql/index";
export const graphqlSchemaAtom = atom<GraphQLSchema | null>(null);
export const graphqlDocStateAtom = atom<boolean>(false);

View File

@@ -113,7 +113,6 @@ function ExportDataDialogContent({
}
/>
</td>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions,jsx-a11y/click-events-have-key-events */}
<td
className="py-1 pl-4 text whitespace-nowrap overflow-x-auto hide-scrollbars"
onClick={() => setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))}

View File

@@ -0,0 +1,543 @@
import { useAtomValue } from 'jotai';
import { graphqlSchemaAtom } from '../atoms/graphqlSchemaAtom';
import { Input } from './core/Input';
import type {
GraphQLSchema,
GraphQLOutputType,
GraphQLScalarType,
GraphQLField,
GraphQLList,
GraphQLInputType,
GraphQLNonNull,
GraphQLObjectType,
} from 'graphql';
import { isNonNullType, isListType } from 'graphql';
import { Button } from './core/Button';
import { useEffect, useState } from 'react';
import { IconButton } from './core/IconButton';
import { fuzzyFilter } from 'fuzzbunny';
function getRootTypes(graphqlSchema: GraphQLSchema) {
return (
[
graphqlSchema.getQueryType(),
graphqlSchema.getMutationType(),
graphqlSchema.getSubscriptionType(),
].filter(Boolean) as NonNullable<ReturnType<GraphQLSchema['getQueryType']>>[]
).reduce(
(prev, curr) => {
return {
...prev,
[curr.name]: curr,
};
},
{} as Record<string, NonNullable<ReturnType<GraphQLSchema['getQueryType']>>>,
);
}
function getTypeIndices(
type: GraphQLAnyType,
context: IndexGenerationContext,
): SearchIndexRecord[] {
const indices: SearchIndexRecord[] = [];
if (!(type as GraphQLObjectType).name) {
return indices;
}
indices.push({
name: (type as GraphQLObjectType).name,
type: 'type',
schemaPointer: type,
args: '',
});
if ((type as GraphQLObjectType).getFields) {
indices.push(...getFieldsIndices((type as GraphQLObjectType).getFields(), context));
}
// remove duplicates from index
return indices.filter(
(x, i, array) => array.findIndex((y) => y.name === x.name && y.type === x.type) === i,
);
}
function getFieldsIndices(
fieldMap: FieldsMap,
context: IndexGenerationContext,
): SearchIndexRecord[] {
const indices: SearchIndexRecord[] = [];
Object.values(fieldMap).forEach((field) => {
if (!field.name) {
return;
}
const args =
field.args && field.args.length > 0 ? field.args.map((arg) => arg.name).join(', ') : '';
indices.push({
name: field.name,
type: context.rootType,
schemaPointer: field as unknown as Field,
args,
});
if (field.type) {
indices.push(...getTypeIndices(field.type, context));
}
});
// remove duplicates from index
return indices.filter(
(x, i, array) => array.findIndex((y) => y.name === x.name && y.type === x.type) === i,
);
}
type Field = NonNullable<ReturnType<GraphQLSchema['getQueryType']>>;
type FieldsMap = ReturnType<Field['getFields']>;
type GraphQLAnyType = FieldsMap[string]['type'];
type SearchIndexRecord = {
name: string;
args: string;
type: 'field' | 'type' | 'Query' | 'Mutation' | 'Subscription';
schemaPointer: SchemaPointer;
};
type IndexGenerationContext = {
rootType: 'Query' | 'Mutation' | 'Subscription';
};
type SchemaPointer = Field | GraphQLOutputType | GraphQLInputType | null;
type ViewMode = 'explorer' | 'search' | 'field';
type HistoryRecord = {
schemaPointer: SchemaPointer;
viewMode: ViewMode;
};
function DocsExplorer({ graphqlSchema }: { graphqlSchema: GraphQLSchema }) {
const [rootTypes, setRootTypes] = useState(getRootTypes(graphqlSchema));
const [schemaPointer, setSchemaPointer] = useState<SchemaPointer>(null);
const [history, setHistory] = useState<HistoryRecord[]>([]);
const [searchIndex, setSearchIndex] = useState<SearchIndexRecord[]>([]);
const [searchQuery, setSearchQuery] = useState<string>('');
const [searchResults, setSearchResults] = useState<SearchIndexRecord[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>('explorer');
useEffect(() => {
setRootTypes(getRootTypes(graphqlSchema));
}, [graphqlSchema]);
useEffect(() => {
const typeMap = graphqlSchema.getTypeMap();
const index: SearchIndexRecord[] = Object.values(typeMap)
.filter((x) => !x.name.startsWith('__'))
.map((x) => ({
name: x.name,
type: 'type',
schemaPointer: x,
args: '',
}));
Object.values(rootTypes).forEach((type) => {
index.push(
...getFieldsIndices(type.getFields(), {
rootType: type.name as IndexGenerationContext['rootType'],
}),
);
});
setSearchIndex(
index.filter(
(x, i, array) => array.findIndex((y) => y.name === x.name && y.type === x.type) === i,
),
);
}, [graphqlSchema, rootTypes]);
useEffect(() => {
if (!searchQuery) {
setSearchResults([]);
return;
}
const results = fuzzyFilter(searchIndex, searchQuery, { fields: ['name', 'args'] })
.sort((a, b) => b.score - a.score)
.map((v) => v.item);
setSearchResults(results);
}, [searchIndex, searchQuery]);
const goBack = () => {
if (history.length === 0) {
return;
}
const newHistory = history.slice(0, history.length - 1);
const prevHistoryRecord = newHistory[newHistory.length - 1];
if (prevHistoryRecord) {
const { schemaPointer: newPointer, viewMode } = prevHistoryRecord;
setHistory(newHistory);
setSchemaPointer(newPointer!);
setViewMode(viewMode);
return;
}
goHome();
};
const addToHistory = (historyRecord: HistoryRecord) => {
setHistory([...history, historyRecord]);
};
const goHome = () => {
setHistory([]);
setSchemaPointer(null);
setViewMode('explorer');
};
const renderRootTypes = () => {
return (
<div className="mt-5 flex flex-col gap-3">
{Object.values(rootTypes).map((x) => (
<button
key={x.name}
className="block text-primary cursor-pointer w-fit"
onClick={() => {
addToHistory({
schemaPointer: x,
viewMode: 'explorer',
});
setSchemaPointer(x);
}}
>
{x.name}
</button>
))}
</div>
);
};
const extractActualType = (type: GraphQLField<never, never>['type'] | GraphQLInputType) => {
// check if non-null
if (isNonNullType(type) || isListType(type)) {
return extractActualType((type as GraphQLNonNull<GraphQLOutputType>).ofType);
}
return type;
};
const onTypeClick = (type: GraphQLField<never, never>['type'] | GraphQLInputType) => {
// check if non-null
if (isNonNullType(type)) {
onTypeClick((type as GraphQLNonNull<GraphQLOutputType>).ofType);
return;
}
// check if list
if (isListType(type)) {
onTypeClick((type as GraphQLList<GraphQLOutputType>).ofType);
return;
}
setSchemaPointer(type);
addToHistory({
schemaPointer: type as Field,
viewMode: 'explorer',
});
setViewMode('explorer');
};
const onFieldClick = (field: GraphQLField<unknown, unknown>) => {
setSchemaPointer(field as unknown as Field);
setViewMode('field');
addToHistory({
schemaPointer: field as unknown as Field,
viewMode: 'field',
});
};
const renderSubFieldRecord = (
field: FieldsMap[string],
options?: {
addable?: boolean;
},
) => {
return (
<div className="flex flex-row justify-start items-center">
{options?.addable ? (
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add to query" />
) : null}
<div className="flex flex-col">
<div>
<span> </span>
<button className="cursor-pointer text-primary" onClick={() => onFieldClick(field)}>
{field.name}
</button>
{/* Arguments block */}
{field.args && field.args.length > 0 ? (
<>
<span> ( </span>
{field.args.map((arg, i, array) => (
<>
<button key={arg.name} onClick={() => onTypeClick(arg.type)}>
<span className="text-primary cursor-pointer">{arg.name}</span>
<span> </span>
<span className="text-success underline cursor-pointer">
{arg.type.toString()}
</span>
{i < array.length - 1 ? (
<>
<span> </span>
<span> , </span>
<span> </span>
</>
) : null}
</button>
<span> </span>
</>
))}
<span>)</span>
</>
) : null}
{/* End of Arguments Block */}
<span> </span>
<button
className="text-success underline cursor-pointer"
onClick={() => onTypeClick(field.type)}
>
{field.type.toString()}
</button>
</div>
{field.description ? <div>{field.description}</div> : null}
</div>
</div>
);
};
const renderScalarField = () => {
const scalarField = schemaPointer as GraphQLScalarType;
return <div>{scalarField.toConfig().description}</div>;
};
const renderSubFields = () => {
if (!schemaPointer) {
return null;
}
if (!(schemaPointer as Field).getFields) {
// Scalar field
return renderScalarField();
}
if (!(schemaPointer as Field).getFields()) {
return null;
}
return Object.values((schemaPointer as Field).getFields()).map((x) =>
renderSubFieldRecord(x, { addable: true }),
);
};
const renderFieldDocView = () => {
if (!schemaPointer) {
return null;
}
return (
<div>
<div className="text-primary mt-5">{(schemaPointer as Field).name}</div>
{(schemaPointer as Field).getFields ? <div className="my-3">Fields</div> : null}
<div className="flex flex-col gap-7">{renderSubFields()}</div>
</div>
);
};
const renderExplorerView = () => {
if (history.length === 0) {
return renderRootTypes();
}
return renderFieldDocView();
};
const renderFieldView = () => {
if (!schemaPointer) {
return null;
}
const field = schemaPointer as unknown as GraphQLField<unknown, unknown>;
const returnType = extractActualType(field.type);
return (
<div>
<div className="text-primary mt-10">{field.name}</div>
{/* Arguments */}
{field.args && field.args.length > 0 ? (
<div className="mt-8">
<div>Arguments</div>
<div className="mt-2">
<div>
{field.args.map((arg, i, array) => (
<>
<button key={arg.name} onClick={() => onTypeClick(arg.type)}>
<span className="text-primary cursor-pointer">{arg.name}</span>
<span> </span>
<span className="text-success underline cursor-pointer">
{arg.type.toString()}
</span>
{i < array.length - 1 ? (
<>
<span> </span>
<span> , </span>
<span> </span>
</>
) : null}
</button>
<span> </span>
</>
))}
</div>
</div>
</div>
) : null}
{/* End of Arguments */}
{/* Return type */}
<div className="mt-8">
<div>Type</div>
<div className="text-primary mt-2">{returnType.name}</div>
</div>
{/* End of Return type */}
{/* Fields */}
{(returnType as GraphQLObjectType).getFields &&
Object.values((returnType as GraphQLObjectType).getFields()).length > 0 ? (
<div className="mt-8">
<div>Fields</div>
<div className="flex flex-col gap-3 mt-2">
{Object.values((returnType as GraphQLObjectType).getFields()).map((x) =>
renderSubFieldRecord(x),
)}
</div>
</div>
) : null}
{/* End of Fields */}
</div>
);
};
const renderTopBar = () => {
return (
<div className="flex flex-row gap-2">
<Button onClick={goBack}>Back</Button>
<IconButton onClick={goHome} icon="house" title="Go to beginning" />
</div>
);
};
const renderSearchView = () => {
return (
<div>
<div className="mt-5 text-primary">Search results</div>
<div className="mt-4 flex flex-col gap-3">
{searchResults.map((result) => (
<button
key={`${result.name}-${result.type}`}
className="cursor-pointer border border-1 border-border-subtle rounded-md p-2 flex flex-row justify-between hover:bg-surface-highlight transition-colors"
onClick={() => {
if (!result.schemaPointer) {
throw new Error('somehow search result record contains no schema pointer');
}
console.log(result);
if (result.type === 'type') {
onTypeClick(result.schemaPointer);
return;
}
onFieldClick(result.schemaPointer as unknown as GraphQLField<unknown, unknown>);
}}
>
<div className="flex flex-row">
<div className="cursor-pointer">{result.name}</div>
{result.args ? (
<div className="cursor-pointer">
{'( '}
{result.args}
{' )'}
</div>
) : null}
</div>
<div className="cursor-pointer">{result.type}</div>
</button>
))}
</div>
</div>
);
};
const renderView = () => {
if (viewMode === 'field') {
return renderFieldView();
}
if (viewMode === 'search') {
return renderSearchView();
}
return renderExplorerView();
};
return (
<div className="overflow-y-auto pe-3">
<div className="min-h-[35px]">
{history.length > 0 || viewMode === 'search' ? renderTopBar() : null}
</div>
{/* Search bar */}
<div className="relative">
<Input
label="Search docs"
stateKey="search_graphql_docs"
placeholder="Search docs"
hideLabel
defaultValue={searchQuery}
onChange={(value) => {
setSearchQuery(value);
}}
onKeyDown={(e) => {
// check if enter
if (e.key === 'Enter' && viewMode !== 'search') {
addToHistory({
schemaPointer: null,
viewMode: 'search',
});
setViewMode('search');
}
}}
/>
</div>
{/* End of search bar */}
<div>{renderView()}</div>
</div>
);
}
export function GraphQLDocsExplorer() {
const graphqlSchema = useAtomValue(graphqlSchemaAtom);
if (graphqlSchema) {
return <DocsExplorer graphqlSchema={graphqlSchema} />;
}
return <div>There is no schema</div>;
}

View File

@@ -16,6 +16,8 @@ import { Editor } from './core/Editor/Editor';
import { FormattedError } from './core/FormattedError';
import { Icon } from './core/Icon';
import { Separator } from './core/Separator';
import { useAtom } from "jotai";
import { graphqlDocStateAtom, graphqlSchemaAtom } from "../atoms/graphqlSchemaAtom";
type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & {
baseRequest: HttpRequest;
@@ -45,6 +47,8 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
return { query: request.body.query ?? '', variables: request.body.variables ?? '' };
}, [extraEditorProps.forceUpdateKey]);
const [, setGraphqlSchemaAtomValue] = useAtom(graphqlSchemaAtom);
const [isDocOpen, setGraphqlDocStateAtomValue] = useAtom(graphqlDocStateAtom);
const handleChangeQuery = (query: string) => {
const newBody = { query, variables: currentBody.variables || undefined };
@@ -62,100 +66,121 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
useEffect(() => {
if (editorViewRef.current == null) return;
updateSchema(editorViewRef.current, schema ?? undefined);
}, [schema]);
setGraphqlSchemaAtomValue(schema);
}, [schema, setGraphqlSchemaAtomValue]);
const actions = useMemo<EditorProps['actions']>(
() => [
<div key="introspection" className="!opacity-100">
{schema === undefined ? null /* Initializing */ : (
<Dropdown
items={[
{
hidden: !error,
label: (
<Banner color="danger">
<p className="mb-1">Schema introspection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={() => {
showDialog({
title: 'Introspection Failed',
size: 'sm',
id: 'introspection-failed',
render: ({ hide }) => (
<>
<FormattedError>{error ?? 'unknown'}</FormattedError>
<div className="w-full my-4">
<Button
onClick={async () => {
hide();
await refetch();
}}
className="ml-auto"
color="primary"
size="sm"
>
Retry Request
</Button>
</div>
</>
),
});
}}
>
View Error
</Button>
</Banner>
),
type: 'content',
},
{
label: 'Refetch',
leftSlot: <Icon icon="refresh" />,
onSelect: refetch,
},
{
label: 'Clear',
onSelect: clear,
hidden: !schema,
color: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Setting' },
{
label: 'Automatic Introspection',
onSelect: () => {
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
});
},
leftSlot: (
<Icon
icon={
autoIntrospectDisabled?.[baseRequest.id]
? 'check_square_unchecked'
: 'check_square_checked'
}
/>
),
},
]}
>
<div
key="actions"
className="flex flex-row !opacity-100 !shadow"
>
<div>
{ schema === undefined ? null /* Initializing */ : (
<Button
onClick={() => setGraphqlDocStateAtomValue(!isDocOpen)}
size="sm"
variant="border"
title="Refetch Schema"
isLoading={isLoading}
color={error ? 'danger' : 'default'}
forDropdown
title="Open Documentation"
className="me-1"
>
{error ? 'Introspection Failed' : schema ? 'Schema' : 'No Schema'}
<Icon
icon="book_open_text"
/>
</Button>
</Dropdown>
)}
) }
</div>
<div key="introspection" className="!opacity-100">
{schema === undefined ? null /* Initializing */ : (
<Dropdown
items={[
{
hidden: !error,
label: (
<Banner color="danger">
<p className="mb-1">Schema introspection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={() => {
showDialog({
title: 'Introspection Failed',
size: 'sm',
id: 'introspection-failed',
render: ({ hide }) => (
<>
<FormattedError>{error ?? 'unknown'}</FormattedError>
<div className="w-full my-4">
<Button
onClick={async () => {
hide();
await refetch();
}}
className="ml-auto"
color="primary"
size="sm"
>
Retry Request
</Button>
</div>
</>
),
});
}}
>
View Error
</Button>
</Banner>
),
type: 'content',
},
{
label: 'Refetch',
leftSlot: <Icon icon="refresh" />,
onSelect: refetch,
},
{
label: 'Clear',
onSelect: clear,
hidden: !schema,
color: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Setting' },
{
label: 'Automatic Introspection',
onSelect: () => {
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
});
},
leftSlot: (
<Icon
icon={
autoIntrospectDisabled?.[baseRequest.id]
? 'check_square_unchecked'
: 'check_square_checked'
}
/>
),
},
]}
>
<Button
size="sm"
variant="border"
title="Refetch Schema"
isLoading={isLoading}
color={error ? 'danger' : 'default'}
forDropdown
>
{error ? 'Introspection Failed' : schema ? 'Schema' : 'No Schema'}
</Button>
</Dropdown>
)}
</div>
</div>,
],
[
@@ -167,6 +192,8 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
clear,
schema,
setAutoIntrospectDisabled,
isDocOpen,
setGraphqlDocStateAtomValue
],
);

View File

@@ -269,7 +269,7 @@ export function GrpcRequestPane({
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
tabListClassName="mt-1 !mb-1.5"
>
<TabContent value="message">
<GrpcEditor

View File

@@ -4,6 +4,11 @@ import type { HttpRequest } from '@yaakapp-internal/models';
import { SplitLayout } from './core/SplitLayout';
import { HttpRequestPane } from './HttpRequestPane';
import { HttpResponsePane } from './HttpResponsePane';
import { GraphQLDocsExplorer } from "./GraphQLDocsExplorer";
import {
useAtomValue
} from 'jotai';
import { graphqlDocStateAtom } from "../atoms/graphqlSchemaAtom";
interface Props {
activeRequest: HttpRequest;
@@ -11,6 +16,11 @@ interface Props {
}
export function HttpRequestLayout({ activeRequest, style }: Props) {
const {
bodyType,
} = activeRequest;
const isDocOpen = useAtomValue(graphqlDocStateAtom);
return (
<SplitLayout
name="http_layout"
@@ -23,7 +33,24 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
fullHeight={orientation === 'horizontal'}
/>
)}
secondSlot={({ style }) => <HttpResponsePane activeRequestId={activeRequest.id} style={style} />}
secondSlot={
bodyType === 'graphql' && isDocOpen
? () => (
<SplitLayout
name="http_response_layout"
className="gap-1.5"
firstSlot={
({ style }) => <HttpResponsePane activeRequestId={activeRequest.id} style={style} />
}
secondSlot={
() => <GraphQLDocsExplorer />
}
/>
)
: (
({ style }) => <HttpResponsePane activeRequestId={activeRequest.id} style={style} />
)
}
/>
);
}

View File

@@ -350,7 +350,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
tabListClassName="mt-1 !mb-1.5"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -153,7 +153,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3"
tabListClassName="mt-1.5"
tabListClassName="mt-0.5"
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">

View File

@@ -11,6 +11,7 @@ interface Props {
export function ImportDataDialog({ importData }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [filePath, setFilePath] = useLocalStorage<string | null>('importFilePath', null);
return (
<VStack space={5} className="pb-4">
<VStack space={1}>

View File

@@ -66,8 +66,10 @@ export default function Settings({ hide }: Props) {
</HeaderSize>
)}
<Tabs
layout="horizontal"
value={tab}
addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border"
label="Settings"
onChangeValue={setTab}
tabs={tabs.map((value) => ({ value, label: capitalize(value) }))}
@@ -81,7 +83,7 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_THEME} className="pt-3 overflow-y-auto h-full px-4">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_PLUGINS} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_PLUGINS} className="pt-3 h-full px-4 grid grid-rows-1">
<SettingsPlugins />
</TabContent>
<TabContent value={TAB_PROXY} className="pt-3 overflow-y-auto h-full px-4">

View File

@@ -1,8 +1,13 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { Plugin} from '@yaakapp-internal/models';
import type { Plugin } from '@yaakapp-internal/models';
import { pluginsAtom } from '@yaakapp-internal/models';
import type { PluginVersion } from '@yaakapp-internal/plugins';
import { checkPluginUpdates, installPlugin, searchPlugins } from '@yaakapp-internal/plugins';
import type { PluginUpdatesResponse } from '@yaakapp-internal/plugins/bindings/gen_api';
import { useAtomValue } from 'jotai';
import React from 'react';
import React, { useState } from 'react';
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useInstallPlugin } from '../../hooks/useInstallPlugin';
import { usePluginInfo } from '../../hooks/usePluginInfo';
import { useRefreshPlugins } from '../../hooks/usePlugins';
@@ -10,100 +15,267 @@ import { useUninstallPlugin } from '../../hooks/useUninstallPlugin';
import { Button } from '../core/Button';
import { IconButton } from '../core/IconButton';
import { InlineCode } from '../core/InlineCode';
import { Link } from '../core/Link';
import { LoadingIcon } from '../core/LoadingIcon';
import { PlainInput } from '../core/PlainInput';
import { HStack } from '../core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { EmptyStateText } from '../EmptyStateText';
import { SelectFile } from '../SelectFile';
export function SettingsPlugins() {
const [directory, setDirectory] = React.useState<string | null>(null);
const plugins = useAtomValue(pluginsAtom);
const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins();
const [tab, setTab] = useState<string>();
return (
<div className="grid grid-rows-[minmax(0,1fr)_auto] h-full">
{plugins.length === 0 ? (
<div className="pb-4">
<EmptyStateText className="text-center">
Plugins extend the functionality of Yaak.
<br />
Add your first plugin to get started.
</EmptyStateText>
</div>
) : (
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="py-2 text-left">Plugin</th>
<th className="py-2 text-right">Version</th>
<th></th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginInfo key={p.id} plugin={p} />
))}
</tbody>
</table>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (directory == null) return;
createPlugin.mutate(directory);
setDirectory(null);
}}
<div className="h-full">
<Tabs
value={tab}
label="Plugins"
onChangeValue={setTab}
addBorders
tabListClassName="!-ml-3"
tabs={[
{ label: 'Marketplace', value: 'search' },
{ label: 'Installed', value: 'installed' },
]}
>
<footer className="grid grid-cols-[minmax(0,1fr)_auto] -mx-4 py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
<SelectFile
size="xs"
noun="Plugin"
directory
onChange={({ filePath }) => setDirectory(filePath)}
filePath={directory}
/>
<HStack>
{directory && (
<Button size="xs" type="submit" color="primary" className="ml-auto">
Add Plugin
</Button>
)}
<IconButton
size="sm"
icon="refresh"
title="Reload plugins"
spin={refreshPlugins.isPending}
onClick={() => refreshPlugins.mutate()}
/>
<IconButton
size="sm"
icon="help"
title="View documentation"
onClick={() => openUrl('https://feedback.yaak.app/help/articles/6911763-quick-start')}
/>
</HStack>
</footer>
</form>
<TabContent value="search">
<PluginSearch />
</TabContent>
<TabContent value="installed">
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
<InstalledPlugins />
<footer className="grid grid-cols-[minmax(0,1fr)_auto] -mx-4 py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
<SelectFile
size="xs"
noun="Plugin"
directory
onChange={({ filePath }) => setDirectory(filePath)}
filePath={directory}
/>
<HStack>
{directory && (
<Button
size="xs"
color="primary"
className="ml-auto"
onClick={() => {
if (directory == null) return;
createPlugin.mutate(directory);
setDirectory(null);
}}
>
Add Plugin
</Button>
)}
<IconButton
size="sm"
icon="refresh"
title="Reload plugins"
spin={refreshPlugins.isPending}
onClick={() => refreshPlugins.mutate()}
/>
<IconButton
size="sm"
icon="help"
title="View documentation"
onClick={() =>
openUrl('https://feedback.yaak.app/help/articles/6911763-quick-start')
}
/>
</HStack>
</footer>
</div>
</TabContent>
</Tabs>
</div>
);
}
function PluginInfo({ plugin }: { plugin: Plugin }) {
function PluginTableRow({
plugin,
updates,
}: {
plugin: Plugin;
updates: PluginUpdatesResponse | null;
}) {
const pluginInfo = usePluginInfo(plugin.id);
const deletePlugin = useUninstallPlugin(plugin.id);
const uninstallPlugin = useUninstallPlugin();
const latestVersion = updates?.plugins.find((u) => u.name === pluginInfo.data?.name)?.version;
const installPluginMutation = useMutation({
mutationKey: ['install_plugin', plugin.id],
mutationFn: (name: string) => installPlugin(name, null),
});
if (pluginInfo.data == null) return null;
return (
<tr className="group">
<td className="py-2 select-text cursor-text w-full">{pluginInfo.data?.name}</td>
<td className="py-2 select-text cursor-text text-right">
<TableRow>
<TableCell className="font-semibold">
{plugin.url ? (
<Link noUnderline href={plugin.url}>
{pluginInfo.data.displayName}
</Link>
) : (
pluginInfo.data.displayName
)}
</TableCell>
<TableCell>
<InlineCode>{pluginInfo.data?.version}</InlineCode>
</td>
<td className="py-2 select-text cursor-text pl-2">
<IconButton
size="sm"
icon="trash"
title="Uninstall plugin"
onClick={() => deletePlugin.mutate()}
/>
</td>
</tr>
</TableCell>
<TableCell className="w-full text-text-subtle">{pluginInfo.data.description}</TableCell>
<TableCell>
<HStack>
{latestVersion != null && (
<Button
variant="border"
color="success"
title={`Update to ${latestVersion}`}
size="xs"
isLoading={installPluginMutation.isPending}
onClick={() => installPluginMutation.mutate(pluginInfo.data.name)}
>
Update
</Button>
)}
<IconButton
size="sm"
icon="trash"
title="Uninstall plugin"
onClick={async () => {
uninstallPlugin.mutate({ pluginId: plugin.id, name: pluginInfo.data.displayName });
}}
/>
</HStack>
</TableCell>
</TableRow>
);
}
function PluginSearch() {
const [query, setQuery] = useState<string>('');
const debouncedQuery = useDebouncedValue(query);
const results = useQuery({
queryKey: ['plugins', debouncedQuery],
queryFn: () => searchPlugins(query),
});
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-3">
<HStack space={1.5}>
<PlainInput
hideLabel
label="Search"
placeholder="Search plugins..."
onChange={setQuery}
defaultValue={query}
/>
</HStack>
<div className="w-full h-full overflow-auto">
{results.data == null ? (
<EmptyStateText>
<LoadingIcon size="xl" className="text-text-subtlest" />
</EmptyStateText>
) : (results.data.plugins ?? []).length === 0 ? (
<EmptyStateText>No plugins found</EmptyStateText>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<TableBody>
{results.data.plugins.map((plugin) => (
<TableRow key={plugin.id}>
<TableCell className="font-semibold">
<Link noUnderline href={plugin.url}>
{plugin.displayName}
</Link>
</TableCell>
<TableCell>
<InlineCode>{plugin.version}</InlineCode>
</TableCell>
<TableCell className="w-full text-text-subtle">
{plugin.description ?? 'n/a'}
</TableCell>
<TableCell>
<InstallPluginButton plugin={plugin} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}
function InstallPluginButton({ plugin }: { plugin: PluginVersion }) {
const plugins = useAtomValue(pluginsAtom);
const uninstallPlugin = useUninstallPlugin();
const installed = plugins?.some((p) => p.id === plugin.id);
const installPluginMutation = useMutation({
mutationKey: ['install_plugin', plugin.id],
mutationFn: (pv: PluginVersion) => installPlugin(pv.name, null),
});
return (
<Button
size="xs"
variant="border"
color={installed ? 'secondary' : 'primary'}
className="ml-auto"
isLoading={installPluginMutation.isPending}
onClick={async () => {
if (installed) {
uninstallPlugin.mutate({ pluginId: plugin.id, name: plugin.displayName });
} else {
installPluginMutation.mutate(plugin);
}
}}
>
{installed ? 'Uninstall' : 'Install'}
</Button>
);
}
function InstalledPlugins() {
const plugins = useAtomValue(pluginsAtom);
const updates = useQuery({
queryKey: ['plugin_updates'],
queryFn: () => checkPluginUpdates(),
});
return plugins.length === 0 ? (
<div className="pb-4">
<EmptyStateText className="text-center">
Plugins extend the functionality of Yaak.
<br />
Add your first plugin to get started.
</EmptyStateText>
</div>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => {
return <PluginTableRow key={p.id} plugin={p} updates={updates.data ?? null} />;
})}
</tbody>
</Table>
);
}

View File

@@ -2,12 +2,12 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import { useLicense } from '@yaakapp-internal/license';
import { useRef } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../lib/appInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { appInfo } from '../lib/appInfo';
import { showDialog } from '../lib/dialog';
import { importData } from '../lib/importData';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
@@ -15,7 +15,6 @@ import { IconButton } from './core/IconButton';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
export function SettingsDropdown() {
const importData = useImportData();
const exportData = useExportData();
const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates();

View File

@@ -10,7 +10,7 @@ export type ToastInstance = {
id: string;
uniqueKey: string;
message: ReactNode;
timeout: 3000 | 5000 | 8000 | null;
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
onClose?: ToastProps['onClose'];
} & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>;

View File

@@ -234,7 +234,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
tabListClassName="mt-1 !mb-1.5"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -4,14 +4,19 @@ import { useAtomValue } from 'jotai';
import * as m from 'motion/react-m';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useEnsureActiveCookieJar, useSubscribeActiveCookieJarId } from '../hooks/useActiveCookieJar';
import { activeEnvironmentAtom, useSubscribeActiveEnvironmentId } from '../hooks/useActiveEnvironment';
import {
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
} from '../hooks/useActiveCookieJar';
import {
activeEnvironmentAtom,
useSubscribeActiveEnvironmentId,
} from '../hooks/useActiveEnvironment';
import { activeRequestAtom } from '../hooks/useActiveRequest';
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import { useHotKey } from '../hooks/useHotKey';
import { useImportData } from '../hooks/useImportData';
import { useSubscribeRecentCookieJars } from '../hooks/useRecentCookieJars';
import { useSubscribeRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useSubscribeRecentRequests } from '../hooks/useRecentRequests';
@@ -22,6 +27,7 @@ import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { duplicateRequestAndNavigate } from '../lib/duplicateRequestAndNavigate';
import { importData } from '../lib/importData';
import { jotaiStore } from '../lib/jotai';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
@@ -204,7 +210,6 @@ export function Workspace() {
function WorkspaceBody() {
const activeRequest = useAtomValue(activeRequestAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const importData = useImportData();
if (activeWorkspace == null) {
return (

View File

@@ -2,7 +2,7 @@ import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view';
const REGEX =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+*~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\]]*))/g;
/(https?:\/\/([-a-zA-Z0-9@:%._+*~#=]{1,256})+(\.[a-zA-Z0-9()]{1,6})?\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\]]*))/g;
const tooltip = hoverTooltip(
(view, pos, side) => {

View File

@@ -23,6 +23,7 @@ const icons = {
arrow_up_from_line: lucide.ArrowUpFromLineIcon,
badge_check: lucide.BadgeCheckIcon,
box: lucide.BoxIcon,
book_open_text: lucide.BookOpenText,
cake: lucide.CakeIcon,
chat: lucide.MessageSquare,
check: lucide.CheckIcon,
@@ -32,6 +33,7 @@ const icons = {
chevron_down: lucide.ChevronDownIcon,
chevron_right: lucide.ChevronRightIcon,
circle_alert: lucide.CircleAlertIcon,
circle_fading_arrow_up: lucide.CircleFadingArrowUpIcon,
clock: lucide.ClockIcon,
code: lucide.CodeIcon,
columns_2: lucide.Columns2Icon,
@@ -132,7 +134,7 @@ export const Icon = memo(function Icon({
title={title}
className={classNames(
className,
'flex-shrink-0 transform-cpu',
'flex-shrink-0',
size === 'xl' && 'h-6 w-6',
size === 'lg' && 'h-5 w-5',
size === 'md' && 'h-4 w-4',

View File

@@ -6,6 +6,7 @@ import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon';
export type IconButtonProps = IconProps &
ButtonProps & {
@@ -31,6 +32,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
iconSize,
showBadge,
iconColor,
isLoading,
...props
}: IconButtonProps,
ref,
@@ -70,18 +72,22 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
<div className="w-2.5 h-2.5 bg-pink-500 rounded-full" />
</div>
)}
<Icon
size={iconSize}
icon={confirmed ? 'check' : icon}
spin={spin}
color={iconColor}
className={classNames(
iconClassName,
'group-hover/button:text-text',
confirmed && '!text-success', // Don't use Icon.color here because it won't override the hover color
props.disabled && 'opacity-70',
)}
/>
{isLoading ? (
<LoadingIcon size={iconSize} className={iconClassName} />
) : (
<Icon
size={iconSize}
icon={confirmed ? 'check' : icon}
spin={spin}
color={iconColor}
className={classNames(
iconClassName,
'group-hover/button:text-text',
confirmed && '!text-success', // Don't use Icon.color here because it won't override the hover color
props.disabled && 'opacity-70',
)}
/>
)}
</Button>
);
});

View File

@@ -6,12 +6,13 @@ import { Icon } from './Icon';
interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string;
noUnderline?: boolean;
}
export function Link({ href, children, className, ...other }: Props) {
export function Link({ href, children, noUnderline, className, ...other }: Props) {
const isExternal = href.match(/^https?:\/\//);
className = classNames(className, 'relative underline hover:text-violet-600');
className = classNames(className, 'relative');
if (isExternal) {
let finalHref = href;
@@ -25,13 +26,17 @@ export function Link({ href, children, className, ...other }: Props) {
href={finalHref}
target="_blank"
rel="noopener noreferrer"
className={classNames(className, 'pr-4 inline-flex items-center')}
className={classNames(
className,
'pr-4 inline-flex items-center hover:underline',
!noUnderline && 'underline',
)}
onClick={(e) => {
e.preventDefault();
}}
{...other}
>
<span className="underline">{children}</span>
<span>{children}</span>
<Icon className="inline absolute right-0.5 top-[0.3em]" size="xs" icon="external_link" />
</a>
);

View File

@@ -53,7 +53,7 @@ export function TableHeaderCell({
children,
className,
}: {
children: ReactNode;
children?: ReactNode;
className?: string;
}) {
return (

View File

@@ -1,11 +1,10 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useEffect, useRef } from 'react';
import { ErrorBoundary } from '../../ErrorBoundary';
import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
import { HStack } from '../Stacks';
import { ErrorBoundary } from '../../ErrorBoundary';
export type TabItem =
| {
@@ -28,6 +27,7 @@ interface Props {
className?: string;
children: ReactNode;
addBorders?: boolean;
layout?: 'horizontal' | 'vertical';
}
export function Tabs({
@@ -39,6 +39,7 @@ export function Tabs({
className,
tabListClassName,
addBorders,
layout = 'vertical',
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
@@ -49,7 +50,10 @@ export function Tabs({
const tabs = ref.current?.querySelectorAll<HTMLDivElement>(`[data-tab]`);
for (const tab of tabs ?? []) {
const v = tab.getAttribute('data-tab');
if (v === value) {
const parent = tab.closest('.tabs-container');
if (parent !== ref.current) {
// Tab is part of a nested tab container, so ignore it
} else if (v === value) {
tab.setAttribute('tabindex', '-1');
tab.setAttribute('data-state', 'active');
tab.setAttribute('aria-hidden', 'false');
@@ -67,29 +71,41 @@ export function Tabs({
ref={ref}
className={classNames(
className,
'tabs-container',
'transform-gpu',
'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'h-full grid',
layout === 'horizontal' && 'grid-rows-1 grid-cols-[auto_minmax(0,1fr)]',
layout === 'vertical' && 'grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
)}
>
<div
aria-label={label}
className={classNames(
tabListClassName,
addBorders && '!-ml-1 h-md mt-2',
'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2',
addBorders && '!-ml-1',
'flex items-center hide-scrollbars mb-2',
layout === 'horizontal' && 'h-full overflow-auto pt-1 px-2',
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
// Give space for button focus states within overflow boundary.
'-ml-5 pl-3 pr-1 py-1',
layout === 'vertical' && 'py-1 -ml-5 pl-3 pr-1',
)}
>
<HStack space={2} className="h-full flex-shrink-0">
<div
className={classNames(
layout === 'horizontal' && 'flex flex-col gap-1 w-full mt-1 pb-3 mb-auto',
layout === 'vertical' && 'flex flex-row flex-shrink-0 gap-2 w-full',
)}
>
{tabs.map((t) => {
const isActive = t.value === value;
const btnClassName = classNames(
'h-full flex items-center rounded',
'h-sm flex items-center rounded',
'!px-2 ml-[1px]',
addBorders && 'border',
isActive ? 'text-text' : 'text-text-subtle hover:text-text',
isActive && addBorders ? 'border-border-subtle' : 'border-transparent',
isActive && addBorders
? 'border-border-subtle bg-surface-active'
: 'border-transparent',
);
if ('options' in t) {
@@ -135,7 +151,7 @@ export function Tabs({
);
}
})}
</HStack>
</div>
</div>
{children}
</div>

View File

@@ -1,109 +0,0 @@
import type { BatchUpsertResult } from '@yaakapp-internal/models';
import { Button } from '../components/core/Button';
import { FormattedError } from '../components/core/FormattedError';
import { VStack } from '../components/core/Stacks';
import { ImportDataDialog } from '../components/ImportDataDialog';
import { showAlert } from '../lib/alert';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
import { pluralizeCount } from '../lib/pluralize';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
import { activeWorkspaceAtom } from './useActiveWorkspace';
import { useFastMutation } from './useFastMutation';
export function useImportData() {
const importData = async (filePath: string): Promise<boolean> => {
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
const imported = await invokeCmd<BatchUpsertResult>('cmd_import_data', {
filePath,
workspaceId: activeWorkspace?.id,
});
const importedWorkspace = imported.workspaces[0];
showDialog({
id: 'import-complete',
title: 'Import Complete',
size: 'sm',
hideX: true,
render: ({ hide }) => {
return (
<VStack space={3} className="pb-4">
<ul className="list-disc pl-6">
<li>{pluralizeCount('Workspace', imported.workspaces.length)}</li>
{imported.environments.length > 0 && (
<li>{pluralizeCount('Environment', imported.environments.length)}</li>
)}
{imported.folders.length > 0 && (
<li>{pluralizeCount('Folder', imported.folders.length)}</li>
)}
{imported.httpRequests.length > 0 && (
<li>{pluralizeCount('HTTP Request', imported.httpRequests.length)}</li>
)}
{imported.grpcRequests.length > 0 && (
<li>{pluralizeCount('GRPC Request', imported.grpcRequests.length)}</li>
)}
{imported.websocketRequests.length > 0 && (
<li>{pluralizeCount('Websocket Request', imported.websocketRequests.length)}</li>
)}
</ul>
<div>
<Button className="ml-auto" onClick={hide} color="primary">
Done
</Button>
</div>
</VStack>
);
},
});
if (importedWorkspace != null) {
const environmentId = imported.environments[0]?.id ?? null;
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: importedWorkspace.id },
search: { environment_id: environmentId },
});
}
return true;
};
return useFastMutation({
mutationKey: ['import_data'],
onError: (err: string) => {
showAlert({
id: 'import-failed',
title: 'Import Failed',
size: 'md',
body: <FormattedError>{err}</FormattedError>,
});
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
showDialog({
id: 'import',
title: 'Import Data',
size: 'sm',
render: ({ hide }) => {
const importAndHide = async (filePath: string) => {
try {
const didImport = await importData(filePath);
if (!didImport) {
return;
}
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
};
return <ImportDataDialog importData={importAndHide} />;
},
});
});
},
});
}

View File

@@ -13,6 +13,7 @@ export function useNotificationToast() {
id: string;
timestamp: string;
message: string;
timeout?: number | null;
action?: null | {
url: string;
label: string;
@@ -23,7 +24,7 @@ export function useNotificationToast() {
const actionLabel = payload.action?.label;
showToast({
id: payload.id,
timeout: null,
timeout: payload.timeout ?? undefined,
message: payload.message,
onClose: () => {
markRead(payload.id)

View File

@@ -1,19 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import type { BootResponse } from '@yaakapp-internal/plugins';
import type { Plugin } from '@yaakapp-internal/models';
import { pluginsAtom } from '@yaakapp-internal/models';
import type { PluginMetadata } from '@yaakapp-internal/plugins';
import { useAtomValue } from 'jotai';
import { queryClient } from '../lib/queryClient';
import { invokeCmd } from '../lib/tauri';
function pluginInfoKey(id: string) {
return ['plugin_info', id];
function pluginInfoKey(id: string, plugin: Plugin | null) {
return ['plugin_info', id, plugin?.updatedAt ?? 'n/a'];
}
export function usePluginInfo(id: string) {
const plugins = useAtomValue(pluginsAtom);
// Get the plugin so we can refetch whenever it's updated
const plugin = plugins.find((p) => p.id === id);
return useQuery({
queryKey: pluginInfoKey(id),
queryFn: async () => {
const info = (await invokeCmd('cmd_plugin_info', { id })) as BootResponse;
return info;
},
queryKey: pluginInfoKey(id, plugin ?? null),
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: () => invokeCmd<PluginMetadata>('cmd_plugin_info', { id }),
});
}

View File

@@ -18,7 +18,6 @@ export function useSyncWorkspaceRequestTitle() {
newTitle += ` [${activeEnvironment.name}]`;
}
if (activeRequest) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
newTitle += ` ${resolvedModelName(activeRequest)}`;
}

View File

@@ -1,12 +0,0 @@
import { useFastMutation } from './useFastMutation';
import type { Plugin } from '@yaakapp-internal/models';
import { invokeCmd } from '../lib/tauri';
export function useUninstallPlugin(pluginId: string) {
return useFastMutation<Plugin | null, string>({
mutationKey: ['uninstall_plugin'],
mutationFn: async () => {
return invokeCmd('cmd_uninstall_plugin', { pluginId });
},
});
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { InlineCode } from '../components/core/InlineCode';
import { showConfirmDelete } from '../lib/confirm';
import { invokeCmd } from '../lib/tauri';
import { useFastMutation } from './useFastMutation';
export function useUninstallPlugin() {
return useFastMutation({
mutationKey: ['uninstall_plugin'],
mutationFn: async ({ pluginId, name }: { pluginId: string; name: string }) => {
const confirmed = await showConfirmDelete({
id: 'uninstall-plugin-' + name,
title: 'Uninstall Plugin',
confirmText: 'Uninstall',
description: (
<>
Permanently uninstall <InlineCode>{name}</InlineCode>?
</>
),
});
if (confirmed) {
await invokeCmd('cmd_uninstall_plugin', { pluginId });
}
},
});
}

View File

@@ -9,9 +9,8 @@ export async function tryFormatJson(text: string): Promise<string> {
try {
const result = await invokeCmd<string>('cmd_format_json', { text });
return result;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
console.warn("Failed to format JSON", err);
console.warn('Failed to format JSON', err);
// Nothing
}

107
src-web/lib/importData.tsx Normal file
View File

@@ -0,0 +1,107 @@
import type { BatchUpsertResult } from '@yaakapp-internal/models';
import { Button } from '../components/core/Button';
import { FormattedError } from '../components/core/FormattedError';
import { VStack } from '../components/core/Stacks';
import { ImportDataDialog } from '../components/ImportDataDialog';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { showAlert } from './alert';
import { showDialog } from './dialog';
import { jotaiStore } from './jotai';
import { pluralizeCount } from './pluralize';
import { router } from './router';
import { invokeCmd } from './tauri';
export const importData = createFastMutation({
mutationKey: ['import_data'],
onError: (err: string) => {
showAlert({
id: 'import-failed',
title: 'Import Failed',
size: 'md',
body: <FormattedError>{err}</FormattedError>,
});
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
showDialog({
id: 'import',
title: 'Import Data',
size: 'sm',
render: ({ hide }) => {
const importAndHide = async (filePath: string) => {
try {
const didImport = await performImport(filePath);
if (!didImport) {
return;
}
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
};
return <ImportDataDialog importData={importAndHide} />;
},
});
});
},
});
async function performImport(filePath: string): Promise<boolean> {
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
const imported = await invokeCmd<BatchUpsertResult>('cmd_import_data', {
filePath,
workspaceId: activeWorkspace?.id,
});
const importedWorkspace = imported.workspaces[0];
showDialog({
id: 'import-complete',
title: 'Import Complete',
size: 'sm',
hideX: true,
render: ({ hide }) => {
return (
<VStack space={3} className="pb-4">
<ul className="list-disc pl-6">
<li>{pluralizeCount('Workspace', imported.workspaces.length)}</li>
{imported.environments.length > 0 && (
<li>{pluralizeCount('Environment', imported.environments.length)}</li>
)}
{imported.folders.length > 0 && (
<li>{pluralizeCount('Folder', imported.folders.length)}</li>
)}
{imported.httpRequests.length > 0 && (
<li>{pluralizeCount('HTTP Request', imported.httpRequests.length)}</li>
)}
{imported.grpcRequests.length > 0 && (
<li>{pluralizeCount('GRPC Request', imported.grpcRequests.length)}</li>
)}
{imported.websocketRequests.length > 0 && (
<li>{pluralizeCount('Websocket Request', imported.websocketRequests.length)}</li>
)}
</ul>
<div>
<Button className="ml-auto" onClick={hide} color="primary">
Done
</Button>
</div>
</VStack>
);
},
});
if (importedWorkspace != null) {
const environmentId = imported.environments[0]?.id ?? null;
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: importedWorkspace.id },
search: { environment_id: environmentId },
});
}
return true;
}