Compare commits

..

21 Commits

Author SHA1 Message Date
Gregory Schier
1a1751c23e Fix window path issue 2025-10-02 08:25:00 -07:00
Gregory Schier
17de0678b0 Remove unused import from window.rs 2025-10-02 08:05:07 -07:00
Gregory Schier
20bb89de33 Try fix oauth window creation 2025-10-02 07:45:50 -07:00
Gregory Schier
8a634b1056 Add back environment.base (#260) 2025-10-02 06:04:27 -07:00
Gregory Schier
57f231ca00 Add trial status to links 2025-10-01 21:14:26 -07:00
Gregory Schier
cb1c0e4d8c Fix ref 2025-10-01 21:07:44 -07:00
Gregory Schier
2152cf87d7 Tweak license badge and fix keyring dep 2025-10-01 21:01:27 -07:00
Gregory Schier
8662b230e7 Oops, actually fix 2025-10-01 16:54:05 -07:00
Gregory Schier
3a8a6484c7 Fix release windows signing 2025-10-01 16:42:38 -07:00
Gregory Schier
f92594a16d Fix release tauri config 2025-10-01 10:51:26 -07:00
Gregory Schier
7969fcb76c Alias keyring function 2025-10-01 10:22:06 -07:00
Gregory Schier
eafefb1894 Fix setting 2025-10-01 09:44:18 -07:00
Gregory Schier
9a94a15c82 Integrated update experience (#259) 2025-10-01 09:36:36 -07:00
Gregory Schier
757d28c235 License and updater Cargo features (#258) 2025-09-29 22:08:05 -07:00
Gregory Schier
6c79c1ef3f Rework licensing flows to be more friendly 2025-09-29 15:40:15 -07:00
Gregory Schier
7262eccac5 Fix keyring errors 2025-09-29 10:53:20 -07:00
Gregory Schier
4989a5f759 Add back cmd palette icon 2025-09-29 09:39:15 -07:00
Gregory Schier
0b0b05d29c Add keyring template function 2025-09-29 08:56:24 -07:00
Gregory Schier
b3d6d87bee Delete duplicate folder environments on upsert 2025-09-29 07:48:07 -07:00
Gregory Schier
6abbdc8726 Filter out current variable from autocomplete and show sub environment variables in base environment autocomplete 2025-09-29 06:57:04 -07:00
Gregory Schier
b9613591f8 Update resource links in README.md 2025-09-28 15:38:14 -07:00
74 changed files with 1139 additions and 474 deletions

View File

@@ -113,5 +113,5 @@ jobs:
releaseName: 'Release __VERSION__'
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
prerelease: true
args: '${{ matrix.args }} --config ./src-tauri/tauri.commercial.conf.json'

View File

@@ -31,4 +31,7 @@ visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment
- [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://feedback.yaak.app/help)
- [Yaak vs Postman](https://yaak.app/blog/postman-alternative)
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)

9
package-lock.json generated
View File

@@ -38,6 +38,7 @@
"plugins/template-function-uuid",
"plugins/template-function-xml",
"plugins/themes-yaak",
"src-tauri",
"src-tauri/yaak-crypto",
"src-tauri/yaak-git",
"src-tauri/yaak-fonts",
@@ -4251,6 +4252,10 @@
"resolved": "src-tauri/yaak-sync",
"link": true
},
"node_modules/@yaakapp-internal/tauri": {
"resolved": "src-tauri",
"link": true
},
"node_modules/@yaakapp-internal/templates": {
"resolved": "src-tauri/yaak-templates",
"link": true
@@ -18991,6 +18996,10 @@
"name": "@yaak/themes-yaak",
"version": "0.1.0"
},
"src-tauri": {
"name": "@yaakapp-internal/tauri",
"version": "1.0.0"
},
"src-tauri/yaak-crypto": {
"name": "@yaakapp-internal/crypto",
"version": "1.0.0"

View File

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

View File

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

View File

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

127
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

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

View File

@@ -155,6 +155,7 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
app_version: window.package_info().version.to_string(),
};
let activation_id = get_activation_id(window.app_handle()).await;
let settings = window.db().get_settings();
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

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

View File

@@ -17,7 +17,7 @@ import { DismissibleBanner } from './core/DismissibleBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor.util';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
@@ -151,7 +151,7 @@ export function EnvironmentEditor({
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteVariables='environment'
valueAutocompleteFunctions
forceUpdateKey={`${environment.id}::${forceUpdateKey}`}
pairs={environment.variables}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,10 @@ import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import React, { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize';
import { HStack } from '../core/Stacks';
import type { TabItem } from '../core/Tabs/Tabs';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize';
import { SettingsInterface } from './SettingsInterface';
@@ -72,24 +74,30 @@ export default function Settings({ hide }: Props) {
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
onChangeValue={setTab}
tabs={tabs.map((value) => ({ value, label: capitalize(value) }))}
tabs={tabs.map(
(value): TabItem => ({
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
}),
)}
>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-8 !py-4">
<SettingsGeneral />
</TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-8 !py-4">
<SettingsInterface />
</TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-8 !py-4">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full px-4 grid grid-rows-1">
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-8 !py-4">
<SettingsPlugins />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-8 !py-4">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-4">
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-8 !py-4">
<SettingsLicense />
</TabContent>
</Tabs>

View File

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

View File

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

View File

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

View File

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

View File

@@ -196,7 +196,7 @@ function InitializedTemplateFunctionDialog({
</HStack>
<InlineCode
className={classNames(
'whitespace-pre-wrap select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
tooLarge && 'italic text-danger',
)}
>

View File

@@ -1,7 +1,9 @@
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import { useAtom, useAtomValue } from 'jotai';
import React, { memo } from 'react';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { workspaceLayoutAtom } from '../lib/atoms';
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
import { CookieDropdown } from './CookieDropdown';
@@ -22,6 +24,7 @@ interface Props {
}
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const togglePalette = useToggleCommandPalette();
const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
@@ -56,7 +59,6 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
) : (
<LicenseBadge />
)}
<SettingsDropdown />
<IconButton
icon={
workspaceLayout === 'responsive'
@@ -72,6 +74,14 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
setWorkspaceLayout((prev) => (prev === 'horizontal' ? 'vertical' : 'horizontal'))
}
/>
<IconButton
icon={type() == 'macos' ? 'command' : 'square_terminal'}
title="Search or execute a command"
size="sm"
iconColor="secondary"
onClick={togglePalette}
/>
<SettingsDropdown />
</div>
</div>
);

View File

@@ -1,6 +1,14 @@
import classNames from 'classnames';
import type { ButtonProps } from './Button';
import { Button } from './Button';
export function BadgeButton(props: ButtonProps) {
return <Button size="2xs" variant="border" className="!rounded-full mx-1" {...props} />;
export function BadgeButton({ className, ...props }: ButtonProps) {
return (
<Button
size="2xs"
variant="border"
className={classNames(className, '!rounded-full mx-1 !px-3')}
{...props}
/>
);
}

View File

@@ -0,0 +1,25 @@
import { useState } from 'react';
import type { ButtonProps } from './Button';
import { Button } from './Button';
export function ButtonInfiniteLoading({
onClick,
isLoading,
loadingChildren,
children,
...props
}: ButtonProps & { loadingChildren?: string }) {
const [localIsLoading, setLocalIsLoading] = useState<boolean>(false);
return (
<Button
isLoading={localIsLoading || isLoading}
onClick={(e) => {
setLocalIsLoading(true);
onClick?.(e);
}}
{...props}
>
{localIsLoading ? (loadingChildren ?? children) : children}
</Button>
);
}

View File

@@ -40,7 +40,7 @@ export function Checkbox({
className={classNames(
'appearance-none w-4 h-4 flex-shrink-0 border border-border',
'rounded outline-none ring-0',
!disabled && 'hocus:border-border-focus hocus:bg-focus/[5%] ',
!disabled && 'hocus:border-border-focus hocus:bg-focus/[5%]',
disabled && 'border-dotted',
)}
type="checkbox"
@@ -58,7 +58,7 @@ export function Checkbox({
</div>
</div>
{!hideLabel && (
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
<div className={classNames('text-sm', fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{title}
</div>
)}

View File

@@ -66,7 +66,7 @@ export interface EditorProps {
autoSelect?: boolean;
autocomplete?: GenericCompletionConfig;
autocompleteFunctions?: boolean;
autocompleteVariables?: boolean;
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
className?: string;
defaultValue?: string | null;
disableTabIndent?: boolean;
@@ -138,8 +138,13 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const settings = useAtomValue(settingsAtom);
const allEnvironmentVariables = useEnvironmentVariables(forcedEnvironmentId ?? null);
const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables;
const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);
const environmentVariables = useMemo(() => {
if (!autocompleteVariables) return emptyVariables;
return typeof autocompleteVariables === 'function'
? allEnvironmentVariables.filter(autocompleteVariables)
: allEnvironmentVariables;
}, [allEnvironmentVariables, autocompleteVariables]);
if (settings && wrapLines === undefined) {
wrapLines = settings.editorSoftWrap;

View File

@@ -39,6 +39,7 @@ const icons = {
code: lucide.CodeIcon,
columns_2: lucide.Columns2Icon,
command: lucide.CommandIcon,
credit_card: lucide.CreditCardIcon,
cookie: lucide.CookieIcon,
copy: lucide.CopyIcon,
copy_check: lucide.CopyCheck,

View File

@@ -12,11 +12,11 @@ import {
} from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import type { WrappedEnvironmentVariable } from '../../hooks/useEnvironmentVariables';
import { useRandomKey } from '../../hooks/useRandomKey';
import { useToggle } from '../../hooks/useToggle';
import { languageFromContentType } from '../../lib/contentType';
import { showDialog } from '../../lib/dialog';
import { generateId } from '../../lib/generateId';
import { showPrompt } from '../../lib/prompt';
import { DropMarker } from '../DropMarker';
import { SelectFile } from '../SelectFile';
@@ -24,12 +24,14 @@ import { Button } from './Button';
import { Checkbox } from './Checkbox';
import type { DropdownItem } from './Dropdown';
import { Dropdown } from './Dropdown';
import type { EditorProps } from './Editor/Editor';
import { Editor } from './Editor/Editor';
import type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
import { Input } from './Input';
import { ensurePairId } from './PairEditor.util';
import { PlainInput } from './PlainInput';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
@@ -55,7 +57,7 @@ export type PairEditorProps = {
stateKey: InputProps['stateKey'];
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean;
valueAutocompleteVariables?: boolean | 'environment';
valuePlaceholder?: string;
valueType?: InputProps['type'] | ((pair: Pair) => InputProps['type']);
valueValidate?: InputProps['validate'];
@@ -471,6 +473,15 @@ export function PairEditorRow({
[pair, onEnd],
);
// Filter out the current pair name
const valueAutocompleteVariablesFiltered = useMemo<EditorProps['autocompleteVariables']>(() => {
if (valueAutocompleteVariables === 'environment') {
return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name;
} else {
return valueAutocompleteVariables;
}
}, [pair.name, valueAutocompleteVariables]);
connectDrag(ref);
connectDrop(ref);
@@ -601,7 +612,7 @@ export function PairEditorRow({
placeholder={valuePlaceholder ?? 'value'}
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
autocompleteVariables={valueAutocompleteVariables}
autocompleteVariables={valueAutocompleteVariablesFiltered}
/>
)}
</div>
@@ -761,12 +772,3 @@ function MultilineEditDialog({
</div>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function ensurePairId(p: Pair): PairWithId {
if (typeof p.id === 'string') {
return p as PairWithId;
} else {
return { ...p, id: p.id ?? generateId() };
}
}

View File

@@ -0,0 +1,10 @@
import { generateId } from '../../lib/generateId';
import type { Pair, PairWithId } from './PairEditor';
export function ensurePairId(p: Pair): PairWithId {
if (typeof p.id === 'string') {
return p as PairWithId;
} else {
return { ...p, id: p.id ?? generateId() };
}
}

View File

@@ -10,6 +10,7 @@ export type TabItem =
| {
value: string;
label: string;
hidden?: boolean;
rightSlot?: ReactNode;
}
| {
@@ -97,6 +98,10 @@ export function Tabs({
)}
>
{tabs.map((t) => {
if ('hidden' in t && t.hidden) {
return null;
}
const isActive = t.value === value;
const btnClassName = classNames(
'h-sm flex items-center rounded whitespace-nowrap',

View File

@@ -16,7 +16,7 @@ export interface ToastProps {
className?: string;
timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest['icon'];
icon?: ShowToastRequest['icon'] | null;
color?: ShowToastRequest['color'];
}
@@ -42,7 +42,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
[open],
);
const toastIcon = icon ?? (color && color in ICONS && ICONS[color]);
const toastIcon = icon === null ? null : icon ?? (color && color in ICONS && ICONS[color]);
return (
<m.div

View File

@@ -3,7 +3,7 @@ import { foldersAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { jotaiStore } from '../lib/jotai';
import { isFolderEnvironment } from '../lib/model_util';
import { isBaseEnvironment, isFolderEnvironment } from '../lib/model_util';
import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveRequest } from './useActiveRequest';
import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown';
@@ -24,11 +24,13 @@ export function useEnvironmentVariables(targetEnvironmentId: string | null) {
wrapVariables(folderEnvironments.find((fe) => fe.parentId === f.id) ?? null),
);
// Folder environments also can auto-complete from the active environment
// Add active environment variables to everything except sub environments
const activeEnvironmentVariables =
targetEnvironment == null || isFolderEnvironment(targetEnvironment)
targetEnvironment == null || // Editing request
isFolderEnvironment(targetEnvironment) || // Editing folder variables
isBaseEnvironment(targetEnvironment) // Editing global variables
? wrapVariables(activeEnvironment)
: [];
: wrapVariables(targetEnvironment); // Add own variables for sub environments
const allVariables = [
...folderVariables,

View File

@@ -1,15 +0,0 @@
import { useKeyValue } from './useKeyValue';
interface LicenseConfirmation {
hasDismissedTrial: boolean;
confirmedPersonalUse: boolean;
}
export function useLicenseConfirmation() {
const { set, value } = useKeyValue<LicenseConfirmation>({
key: 'license_confirmation',
fallback: { hasDismissedTrial: false, confirmedPersonalUse: false },
});
return [value, set] as const;
}

View File

@@ -8,9 +8,13 @@ export interface AppInfo {
appDataDir: string;
appLogDir: string;
identifier: string;
featureLicense: boolean;
featureUpdater: boolean;
}
export const appInfo = {
...(await invokeCmd('cmd_metadata')),
identifier: await getIdentifier(),
} as AppInfo;
console.log('App info', appInfo);

View File

@@ -1,5 +1,7 @@
import deepEqual from '@gilbarbara/deep-equal';
import type { UpdateInfo } from '@yaakapp-internal/tauri';
import type { Atom } from 'jotai';
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import type { SplitLayoutLayout } from '../components/core/SplitLayout';
import { atomWithKVStorage } from './atoms/atomWithKVStorage';
@@ -16,3 +18,5 @@ export const workspaceLayoutAtom = atomWithKVStorage<SplitLayoutLayout>(
'workspace_layout',
'horizontal',
);
export const updateAvailableAtom = atom<Omit<UpdateInfo, 'replyEventId'> | null>(null);

20
src-web/lib/color.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { Color } from '@yaakapp-internal/plugins';
const colors: Record<Color, boolean> = {
primary: true,
secondary: true,
success: true,
notice: true,
warning: true,
danger: true,
info: true,
};
export function stringToColor(str: string | null): Color | null {
if (!str) return null;
const strLower = str.toLowerCase();
if (strLower in colors) {
return strLower as Color;
}
return null;
}

View File

@@ -1,12 +1,19 @@
import { emit } from '@tauri-apps/api/event';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { InternalEvent, ShowToastRequest } from '@yaakapp-internal/plugins';
import type { UpdateInfo, UpdateResponse, YaakNotification } from '@yaakapp-internal/tauri';
import { openSettings } from '../commands/openSettings';
import { Button } from '../components/core/Button';
import { ButtonInfiniteLoading } from '../components/core/ButtonInfiniteLoading';
import { Icon } from '../components/core/Icon';
import { HStack, VStack } from '../components/core/Stacks';
// Listen for toasts
import { listenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { updateAvailableAtom } from './atoms';
import { stringToColor } from './color';
import { generateId } from './generateId';
import { jotaiStore } from './jotai';
import { showPrompt } from './prompt';
import { invokeCmd } from './tauri';
import { showToast } from './toast';
@@ -37,40 +44,125 @@ export function initGlobalListeners() {
}
});
listenToTauriEvent<{
id: string;
timestamp: string;
message: string;
timeout?: number | null;
action?: null | {
url: string;
label: string;
};
}>('notification', ({ payload }) => {
console.log('Got notification event', payload);
const actionUrl = payload.action?.url;
const actionLabel = payload.action?.label;
const UPDATE_TOAST_ID = 'update-info'; // Share the ID to replace the toast
listenToTauriEvent<string>('update_installed', async ({ payload: version }) => {
showToast({
id: payload.id,
timeout: payload.timeout ?? undefined,
message: payload.message,
onClose: () => {
invokeCmd('cmd_dismiss_notification', { notificationId: payload.id }).catch(console.error);
},
action: ({ hide }) =>
actionLabel && actionUrl ? (
<Button
size="xs"
color="secondary"
className="mr-auto min-w-[5rem]"
onClick={() => {
hide();
return openUrl(actionUrl);
}}
>
{actionLabel}
</Button>
) : null,
id: UPDATE_TOAST_ID,
color: 'primary',
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">Start using the new version now?</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-[5rem]"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => invokeCmd('cmd_restart', {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
});
});
// Listen for update events
listenToTauriEvent<UpdateInfo>(
'update_available',
async ({ payload: { version, replyEventId, downloaded } }) => {
console.log('Received update available event', { replyEventId, version, downloaded });
jotaiStore.set(updateAvailableAtom, { version, downloaded });
// Acknowledge the event, so we don't time out and try the fallback update logic
await emit<UpdateResponse>(replyEventId, { type: 'ack' });
showToast({
id: UPDATE_TOAST_ID,
color: 'info',
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{downloaded ? 'Do you want to install' : 'Download and install'} the update?
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-[10rem]"
loadingChildren={downloaded ? 'Installing...' : 'Downloading...'}
onClick={async () => {
await emit<UpdateResponse>(replyEventId, { type: 'action', action: 'install' });
}}
>
{downloaded ? 'Install Now' : 'Download and Install'}
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await openUrl('https://yaak.app/changelog/' + version);
}}
>
What&apos;s New
</Button>
</HStack>
),
});
},
);
listenToTauriEvent<YaakNotification>('notification', ({ payload }) => {
console.log('Got notification event', payload);
showNotificationToast(payload);
});
}
function showNotificationToast(n: YaakNotification) {
const actionUrl = n.action?.url;
const actionLabel = n.action?.label;
showToast({
id: n.id,
timeout: n.timeout ?? null,
color: stringToColor(n.color) ?? undefined,
message: (
<VStack>
{n.title && <h2 className="font-semibold">{n.title}</h2>}
<p className="text-text-subtle text-sm">{n.message}</p>
</VStack>
),
onClose: () => {
invokeCmd('cmd_dismiss_notification', { notificationId: n.id }).catch(console.error);
},
action: ({ hide }) => {
return actionLabel && actionUrl ? (
<Button
size="xs"
color={stringToColor(n.color) ?? undefined}
className="mr-auto min-w-[5rem]"
rightSlot={<Icon icon="external_link" />}
onClick={() => {
hide();
return openUrl(actionUrl);
}}
>
{actionLabel}
</Button>
) : null;
},
});
}

View File

@@ -28,6 +28,7 @@ type TauriCmd =
| 'cmd_import_data'
| 'cmd_install_plugin'
| 'cmd_metadata'
| 'cmd_restart'
| 'cmd_new_child_window'
| 'cmd_new_main_window'
| 'cmd_plugin_info'

View File

@@ -118,7 +118,7 @@ function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {
return {
text: color.lift(0.8).css(),
textSubtle: color.css(),
textSubtle: color.lift(0.8).translucify(0.3).css(),
surface: color.translucify(0.9).css(),
surfaceHighlight: color.translucify(0.8).css(),
border: color.lift(0.3).translucify(0.6).css(),

View File

@@ -21,7 +21,6 @@ export function showToast({
let delay = 0;
if (toastWithSameId) {
console.log('HIDING TOAST', id);
hideToast(toastWithSameId);
// Allow enough time for old toast to animate out
delay = 200;