Compare commits

...

18 Commits

Author SHA1 Message Date
Gregory Schier
2e144f064d Fix syntax highlighting 2025-05-21 08:26:15 -07:00
Gregory Schier
d8b1cadae6 Fix model deletion 2025-05-21 08:25:12 -07:00
Gregory Schier
c2f9760d08 Fix template parsing 2025-05-21 08:18:09 -07:00
Gregory Schier
a4c600cb48 Lint errors 2025-05-20 08:15:19 -07:00
Gregory Schier
bc3a5e3e58 Include license status in notification endpoint 2025-05-20 08:13:57 -07:00
Gregory Schier
4c3a02ac53 Show decrypt error in secure input 2025-05-20 07:41:32 -07:00
Gregory Schier
1974d61aa4 Fix syntax highlighting 2025-05-19 15:41:19 -07:00
Gregory Schier
0bcb092854 Update README.md 2025-05-19 15:10:56 -07:00
Gregory Schier
409620f533 More advanced template grammar
Fixes https://feedback.yaak.app/p/cannot-escape-call-to-variable-in-json-body
2025-05-19 13:37:12 -07:00
Gregory Schier
3e9037f70a No longer mark environments as external in Git 2025-05-17 06:06:36 -07:00
Desperate Necromancer
be82b67ed3 Allow disabling window decorations/controls (#176)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2025-05-16 13:33:59 -07:00
Gregory Schier
432b366105 Fix grpc/ws events error 2025-05-16 13:00:50 -07:00
Hao Xiang
42e70b941d fix proto to json-schema (#194) 2025-05-16 12:53:53 -07:00
Gregory Schier
3808215210 Better unicode un-escaping 2025-05-16 12:42:08 -07:00
Walyson G Oliveira
763a60982a Adjusting the JSON viewing response to accept accentuation (#203) 2025-05-16 12:37:00 -07:00
dependabot[bot]
a05679fd93 Bump vite from 6.2.6 to 6.2.7 in /src-web (#205)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 12:31:46 -07:00
Gregory Schier
73c366dc27 Hopefully fix weird env routing issue 2025-05-16 09:12:36 -07:00
Gregory Schier
0be7d0283b Add ref=<app_id> to external links pointing to yaak.app 2025-05-16 07:53:22 -07:00
52 changed files with 716 additions and 392 deletions

View File

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

8
package-lock.json generated
View File

@@ -14044,7 +14044,7 @@
"postcss": "^8.4.45",
"postcss-nesting": "^13.0.0",
"tailwindcss": "^3.4.10",
"vite": "6.2.6",
"vite": "6.2.7",
"vite-plugin-static-copy": "^2.2.0",
"vite-plugin-svgr": "^4.3.0",
"vite-plugin-top-level-await": "^1.5.0",
@@ -14584,9 +14584,9 @@
}
},
"src-web/node_modules/vite": {
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
"version": "6.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.7.tgz",
"integrity": "sha512-qg3LkeuinTrZoJHHF94coSaTfIPyBYoywp+ys4qu20oSJFbKMYoIJo0FWJT9q6Vp49l6z9IsJRbHdcGtiKbGoQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -7,6 +7,7 @@
"*"
],
"permissions": [
"core:app:allow-identifier",
"core:event:allow-emit",
"core:event:allow-listen",
"core:event:allow-unlisten",

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN hide_window_controls BOOLEAN DEFAULT FALSE NOT NULL;

View File

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

View File

@@ -1,16 +1,256 @@
use prost_reflect::{DescriptorPool, MessageDescriptor};
use prost_types::field_descriptor_proto;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use prost_reflect::{DescriptorPool, FieldDescriptor, MessageDescriptor};
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default, Serialize, Deserialize)]
pub fn message_to_json_schema(_: &DescriptorPool, root_msg: MessageDescriptor) -> JsonSchemaEntry {
JsonSchemaGenerator::generate_json_schema(root_msg)
}
struct JsonSchemaGenerator {
msg_mapping: HashMap<String, JsonSchemaEntry>,
}
impl JsonSchemaGenerator {
pub fn new() -> Self {
JsonSchemaGenerator {
msg_mapping: HashMap::new(),
}
}
pub fn generate_json_schema(msg: MessageDescriptor) -> JsonSchemaEntry {
let generator = JsonSchemaGenerator::new();
generator.scan_root(msg)
}
fn add_message(&mut self, msg: &MessageDescriptor) {
let name = msg.full_name().to_string();
if self.msg_mapping.contains_key(&name) {
return;
}
self.msg_mapping.insert(name.clone(), JsonSchemaEntry::object());
}
pub fn scan_root(mut self, root_msg: MessageDescriptor) -> JsonSchemaEntry {
self.init_structure(root_msg.clone());
self.fill_properties(root_msg.clone());
let mut root = self.msg_mapping.remove(root_msg.full_name()).unwrap();
if self.msg_mapping.len() > 0 {
root.defs = Some(self.msg_mapping);
}
root
}
fn fill_properties(&mut self, root_msg: MessageDescriptor) {
let root_name = root_msg.full_name().to_string();
let mut visited = HashSet::new();
let mut msg_queue = VecDeque::new();
msg_queue.push_back(root_msg);
while !msg_queue.is_empty() {
let msg = msg_queue.pop_front().unwrap();
let msg_name = msg.full_name();
if visited.contains(msg_name) {
continue;
}
visited.insert(msg_name.to_string());
let entry = self.msg_mapping.get_mut(msg_name).unwrap();
for field in msg.fields() {
let field_name = field.name().to_string();
if matches!(field.cardinality(), prost_reflect::Cardinality::Required) {
entry.add_required(field_name.clone());
}
if let Some(oneof) = field.containing_oneof() {
for oneof_field in oneof.fields() {
if let Some(fm) = is_message_field(&oneof_field) {
msg_queue.push_back(fm);
}
entry.add_property(
oneof_field.name().to_string(),
field_to_type_or_ref(&root_name, oneof_field),
);
}
continue;
}
let (field_type, nest_msg) = {
if let Some(fm) = is_message_field(&field) {
if field.is_list() {
// repeated message type
(
JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)),
Some(fm),
)
} else if field.is_map() {
let value_field = fm.get_field_by_name("value").unwrap();
if let Some(fm) = is_message_field(&value_field) {
(
JsonSchemaEntry::map(field_to_type_or_ref(
&root_name,
value_field,
)),
Some(fm),
)
} else {
(
JsonSchemaEntry::map(field_to_type_or_ref(
&root_name,
value_field,
)),
None,
)
}
} else {
(field_to_type_or_ref(&root_name, field), Some(fm))
}
} else {
if field.is_list() {
// repeated scalar type
(JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)), None)
} else {
(field_to_type_or_ref(&root_name, field), None)
}
}
};
if let Some(fm) = nest_msg {
msg_queue.push_back(fm);
}
entry.add_property(field_name, field_type);
}
}
}
fn init_structure(&mut self, root_msg: MessageDescriptor) {
let mut visited = HashSet::new();
let mut msg_queue = VecDeque::new();
msg_queue.push_back(root_msg.clone());
// level traversal, to make sure all message type is defined before used
while !msg_queue.is_empty() {
let msg = msg_queue.pop_front().unwrap();
let name = msg.full_name();
if visited.contains(name) {
continue;
}
visited.insert(name.to_string());
self.add_message(&msg);
for child in msg.child_messages() {
if child.is_map_entry() {
// for field with map<key, value> type, there will be a child message type *Entry generated
// just skip it
continue;
}
self.add_message(&child);
msg_queue.push_back(child);
}
for field in msg.fields() {
if let Some(oneof) = field.containing_oneof() {
for oneof_field in oneof.fields() {
if let Some(fm) = is_message_field(&oneof_field) {
self.add_message(&fm);
msg_queue.push_back(fm);
}
}
continue;
}
if field.is_map() {
// key is always scalar type, so no need to process
// value can be any type, so need to unpack value type
let map_field_msg = is_message_field(&field).unwrap();
let map_value_field = map_field_msg.get_field_by_name("value").unwrap();
if let Some(value_fm) = is_message_field(&map_value_field) {
self.add_message(&value_fm);
msg_queue.push_back(value_fm);
}
continue;
}
if let Some(fm) = is_message_field(&field) {
self.add_message(&fm);
msg_queue.push_back(fm);
}
}
}
}
}
fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEntry {
match field.kind() {
prost_reflect::Kind::Bool => JsonSchemaEntry::boolean(),
prost_reflect::Kind::Double => JsonSchemaEntry::number("double"),
prost_reflect::Kind::Float => JsonSchemaEntry::number("float"),
prost_reflect::Kind::Int32 => JsonSchemaEntry::number("int32"),
prost_reflect::Kind::Int64 => JsonSchemaEntry::string_with_format("int64"),
prost_reflect::Kind::Uint32 => JsonSchemaEntry::number("int64"),
prost_reflect::Kind::Uint64 => JsonSchemaEntry::string_with_format("uint64"),
prost_reflect::Kind::Sint32 => JsonSchemaEntry::number("sint32"),
prost_reflect::Kind::Sint64 => JsonSchemaEntry::string_with_format("sint64"),
prost_reflect::Kind::Fixed32 => JsonSchemaEntry::number("int64"),
prost_reflect::Kind::Fixed64 => JsonSchemaEntry::string_with_format("fixed64"),
prost_reflect::Kind::Sfixed32 => JsonSchemaEntry::number("sfixed32"),
prost_reflect::Kind::Sfixed64 => JsonSchemaEntry::string_with_format("sfixed64"),
prost_reflect::Kind::String => JsonSchemaEntry::string(),
prost_reflect::Kind::Bytes => JsonSchemaEntry::string_with_format("byte"),
prost_reflect::Kind::Enum(enums) => {
let values = enums.values().map(|v| v.name().to_string()).collect::<Vec<_>>();
JsonSchemaEntry::enums(values)
}
prost_reflect::Kind::Message(fm) => {
let field_type_full_name = fm.full_name();
match field_type_full_name {
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
"google.protobuf.Duration" => JsonSchemaEntry::string(),
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
"google.protobuf.UInt32Value" => JsonSchemaEntry::string_with_format("int64"),
"google.protobuf.Int64Value" => JsonSchemaEntry::string_with_format("int64"),
"google.protobuf.UInt64Value" => JsonSchemaEntry::string_with_format("uint64"),
"google.protobuf.FloatValue" => JsonSchemaEntry::number("float"),
"google.protobuf.DoubleValue" => JsonSchemaEntry::number("double"),
"google.protobuf.BoolValue" => JsonSchemaEntry::boolean(),
"google.protobuf.Empty" => JsonSchemaEntry::default(),
"google.protobuf.Struct" => JsonSchemaEntry::object(),
"google.protobuf.ListValue" => JsonSchemaEntry::array(JsonSchemaEntry::default()),
"google.protobuf.NullValue" => JsonSchemaEntry::null(),
name @ _ if name == root_name => JsonSchemaEntry::root_reference(),
_ => JsonSchemaEntry::reference(fm.full_name()),
}
}
}
}
fn is_message_field(field: &FieldDescriptor) -> Option<MessageDescriptor> {
match field.kind() {
prost_reflect::Kind::Message(m) => Some(m),
_ => None,
}
}
#[derive(Default, serde::Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct JsonSchemaEntry {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(rename = "type")]
type_: JsonType,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
type_: Option<JsonType>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
@@ -21,15 +261,115 @@ pub struct JsonSchemaEntry {
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
enum_: Option<Vec<String>>,
/// Don't allow any other properties in the object
additional_properties: bool,
// for map type
#[serde(skip_serializing_if = "Option::is_none")]
additional_properties: Option<Box<JsonSchemaEntry>>,
/// Set all properties to required
// Set all properties to required
#[serde(skip_serializing_if = "Option::is_none")]
required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
items: Option<Box<JsonSchemaEntry>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "$defs")]
defs: Option<HashMap<String, JsonSchemaEntry>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "$ref")]
ref_: Option<String>,
}
impl JsonSchemaEntry {
pub fn add_property(&mut self, name: String, entry: JsonSchemaEntry) {
if self.properties.is_none() {
self.properties = Some(HashMap::new());
}
self.properties.as_mut().unwrap().insert(name, entry);
}
pub fn add_required(&mut self, name: String) {
if self.required.is_none() {
self.required = Some(Vec::new());
}
self.required.as_mut().unwrap().push(name);
}
}
impl JsonSchemaEntry {
pub fn object() -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Object),
..Default::default()
}
}
pub fn boolean() -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Boolean),
..Default::default()
}
}
pub fn number<S: Into<String>>(format: S) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Number),
format: Some(format.into()),
..Default::default()
}
}
pub fn string() -> Self {
JsonSchemaEntry {
type_: Some(JsonType::String),
..Default::default()
}
}
pub fn string_with_format<S: Into<String>>(format: S) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::String),
format: Some(format.into()),
..Default::default()
}
}
pub fn reference<S: AsRef<str>>(ref_: S) -> Self {
JsonSchemaEntry {
ref_: Some(format!("#/$defs/{}", ref_.as_ref())),
..Default::default()
}
}
pub fn root_reference() -> Self{
JsonSchemaEntry {
ref_: Some("#".to_string()),
..Default::default()
}
}
pub fn array(item: JsonSchemaEntry) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Array),
items: Some(Box::new(item)),
..Default::default()
}
}
pub fn enums(enums: Vec<String>) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::String),
enum_: Some(enums),
..Default::default()
}
}
pub fn map(value_type: JsonSchemaEntry) -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Object),
additional_properties: Some(Box::new(value_type)),
..Default::default()
}
}
pub fn null() -> Self {
JsonSchemaEntry {
type_: Some(JsonType::Null),
..Default::default()
}
}
}
enum JsonType {
@@ -49,7 +389,7 @@ impl Default for JsonType {
}
impl serde::Serialize for JsonType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
@@ -64,116 +404,3 @@ impl serde::Serialize for JsonType {
}
}
}
impl<'de> serde::Deserialize<'de> for JsonType {
fn deserialize<D>(deserializer: D) -> Result<JsonType, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"string" => Ok(JsonType::String),
"number" => Ok(JsonType::Number),
"object" => Ok(JsonType::Object),
"array" => Ok(JsonType::Array),
"boolean" => Ok(JsonType::Boolean),
"null" => Ok(JsonType::Null),
_ => Ok(JsonType::_UNKNOWN),
}
}
}
pub fn message_to_json_schema(
pool: &DescriptorPool,
message: MessageDescriptor,
) -> JsonSchemaEntry {
let mut schema = JsonSchemaEntry {
title: Some(message.name().to_string()),
type_: JsonType::Object, // Messages are objects
..Default::default()
};
let mut properties = HashMap::new();
message.fields().for_each(|f| match f.kind() {
prost_reflect::Kind::Message(m) => {
properties.insert(f.name().to_string(), message_to_json_schema(pool, m));
}
prost_reflect::Kind::Enum(e) => {
properties.insert(
f.name().to_string(),
JsonSchemaEntry {
type_: map_proto_type_to_json_type(f.field_descriptor_proto().r#type()),
enum_: Some(e.values().map(|v| v.name().to_string()).collect::<Vec<_>>()),
..Default::default()
},
);
}
_ => {
// TODO: Handle repeated label
match f.field_descriptor_proto().label() {
field_descriptor_proto::Label::Repeated => {
// TODO: Handle more complex repeated types. This just handles primitives for now
properties.insert(
f.name().to_string(),
JsonSchemaEntry {
type_: JsonType::Array,
items: Some(Box::new(JsonSchemaEntry {
type_: map_proto_type_to_json_type(
f.field_descriptor_proto().r#type(),
),
..Default::default()
})),
..Default::default()
},
);
}
_ => {
// Regular JSON field
properties.insert(
f.name().to_string(),
JsonSchemaEntry {
type_: map_proto_type_to_json_type(f.field_descriptor_proto().r#type()),
..Default::default()
},
);
}
};
}
});
schema.properties = Some(properties);
// All proto 3 fields are optional, so maybe we could
// make this a setting?
// schema.required = Some(
// message
// .fields()
// .map(|f| f.name().to_string())
// .collect::<Vec<_>>(),
// );
schema
}
fn map_proto_type_to_json_type(proto_type: field_descriptor_proto::Type) -> JsonType {
match proto_type {
field_descriptor_proto::Type::Double => JsonType::Number,
field_descriptor_proto::Type::Float => JsonType::Number,
field_descriptor_proto::Type::Int64 => JsonType::Number,
field_descriptor_proto::Type::Uint64 => JsonType::Number,
field_descriptor_proto::Type::Int32 => JsonType::Number,
field_descriptor_proto::Type::Fixed64 => JsonType::Number,
field_descriptor_proto::Type::Fixed32 => JsonType::Number,
field_descriptor_proto::Type::Bool => JsonType::Boolean,
field_descriptor_proto::Type::String => JsonType::String,
field_descriptor_proto::Type::Group => JsonType::_UNKNOWN,
field_descriptor_proto::Type::Message => JsonType::Object,
field_descriptor_proto::Type::Bytes => JsonType::String,
field_descriptor_proto::Type::Uint32 => JsonType::Number,
field_descriptor_proto::Type::Enum => JsonType::String,
field_descriptor_proto::Type::Sfixed32 => JsonType::Number,
field_descriptor_proto::Type::Sfixed64 => JsonType::Number,
field_descriptor_proto::Type::Sint32 => JsonType::Number,
field_descriptor_proto::Type::Sint64 => JsonType::Number,
}
}

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ export type ProxySetting = { "type": "enabled", disabled: boolean, http: string,
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, };
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

@@ -1,9 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment } from "./gen_models";
import type { Folder } from "./gen_models";
import type { GrpcRequest } from "./gen_models";
import type { HttpRequest } from "./gen_models";
import type { WebsocketRequest } from "./gen_models";
import type { Workspace } from "./gen_models";
import type { Environment } from "./gen_models.js";
import type { Folder } from "./gen_models.js";
import type { GrpcRequest } from "./gen_models.js";
import type { HttpRequest } from "./gen_models.js";
import type { WebsocketRequest } from "./gen_models.js";
import type { Workspace } from "./gen_models.js";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };

View File

@@ -105,6 +105,7 @@ pub struct Settings {
pub appearance: String,
pub editor_font_size: i32,
pub editor_soft_wrap: bool,
pub hide_window_controls: bool,
pub interface_font_size: i32,
pub interface_scale: f32,
pub open_workspace_new_window: Option<bool>,
@@ -154,6 +155,7 @@ impl UpsertModelInfo for Settings {
(EditorSoftWrap, self.editor_soft_wrap.into()),
(InterfaceFontSize, self.interface_font_size.into()),
(InterfaceScale, self.interface_scale.into()),
(HideWindowControls, self.hide_window_controls.into()),
(OpenWorkspaceNewWindow, self.open_workspace_new_window.into()),
(ThemeDark, self.theme_dark.as_str().into()),
(ThemeLight, self.theme_light.as_str().into()),
@@ -171,6 +173,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::EditorSoftWrap,
SettingsIden::InterfaceFontSize,
SettingsIden::InterfaceScale,
SettingsIden::HideWindowControls,
SettingsIden::OpenWorkspaceNewWindow,
SettingsIden::Proxy,
SettingsIden::ThemeDark,
@@ -200,6 +203,7 @@ impl UpsertModelInfo for Settings {
proxy: proxy.map(|p| -> ProxySetting { serde_json::from_str(p.as_str()).unwrap() }),
theme_dark: row.get("theme_dark")?,
theme_light: row.get("theme_light")?,
hide_window_controls: row.get("hide_window_controls")?,
update_channel: row.get("update_channel")?,
})
}

View File

@@ -23,6 +23,7 @@ impl<'a> DbContext<'a> {
editor_soft_wrap: true,
interface_font_size: 15,
interface_scale: 1.0,
hide_window_controls: false,
open_workspace_new_window: None,
proxy: None,
theme_dark: "yaak-dark".to_string(),

View File

@@ -0,0 +1,51 @@
import { createWorkspaceModel, type Environment } from '@yaakapp-internal/models';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { jotaiStore } from '../lib/jotai';
import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
export const createEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ['create_environment'],
mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) {
throw new Error('No base environment passed');
}
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) {
throw new Error('Cannot create environment when no active workspace');
}
const name = await showPrompt({
id: 'new-environment',
title: 'New Environment',
description: 'Create multiple environments with different sets of variables',
label: 'Name',
placeholder: 'My Environment',
defaultValue: 'My Environment',
confirmText: 'Create',
});
if (name == null) return null;
return createWorkspaceModel({
model: 'environment',
name,
variables: [],
workspaceId,
base: false,
});
},
onSuccess: async (environmentId) => {
if (environmentId == null) {
return; // Was not created
}
console.log('NAVIGATING', jotaiStore.get(activeWorkspaceIdAtom), environmentId);
setWorkspaceSearchParams({ environment_id: environmentId });
},
});

View File

@@ -5,6 +5,7 @@ import { useAtomValue } from 'jotai';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFolder } from '../commands/commands';
import { createEnvironmentAndActivate } from '../commands/createEnvironment';
import { openSettings } from '../commands/openSettings';
import { switchWorkspace } from '../commands/switchWorkspace';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
@@ -12,7 +13,6 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { useAllRequests } from '../hooks/useAllRequests';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
@@ -72,7 +72,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const activeCookieJar = useActiveCookieJar();
const [recentRequests] = useRecentRequests();
const [, setSidebarHidden] = useSidebarHidden();
const { mutate: createEnvironment } = useCreateEnvironment();
const { mutate: sendRequest } = useSendAnyHttpRequest();
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
@@ -139,7 +138,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{
key: 'environment.create',
label: 'Create Environment',
onSelect: () => createEnvironment(baseEnvironment),
onSelect: () => createEnvironmentAndActivate.mutate(baseEnvironment),
},
{
key: 'sidebar.toggle',
@@ -190,7 +189,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
activeEnvironment,
activeRequest,
baseEnvironment,
createEnvironment,
createWorkspace,
httpRequestActions,
sendRequest,

View File

@@ -4,7 +4,7 @@ import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { createEnvironmentAndActivate } from '../commands/createEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';
@@ -41,7 +41,6 @@ interface Props {
}
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const createEnvironment = useCreateEnvironment();
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
@@ -55,7 +54,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return;
const id = await createEnvironment.mutateAsync(baseEnvironment);
const id = await createEnvironmentAndActivate.mutateAsync(baseEnvironment);
if (id != null) setSelectedEnvironmentId(id);
};
@@ -162,30 +161,30 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
};
const EnvironmentEditor = function ({
environment: activeEnvironment,
environment: selectedEnvironment,
className,
}: {
environment: Environment;
className?: string;
}) {
const activeWorkspaceId = activeEnvironment.workspaceId;
const workspaceId = selectedEnvironment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
namespace: 'global',
key: ['environmentValueVisibility', activeWorkspaceId],
key: ['environmentValueVisibility', workspaceId],
fallback: false,
});
const { allEnvironments } = useEnvironmentsBreakdown();
const handleChange = useCallback(
(variables: PairWithId[]) => patchModel(activeEnvironment, { variables }),
[activeEnvironment],
(variables: PairWithId[]) => patchModel(selectedEnvironment, { variables }),
[selectedEnvironment],
);
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const options: GenericCompletionOption[] = [];
if (activeEnvironment.base) {
if (selectedEnvironment.base) {
return { options };
}
@@ -195,7 +194,7 @@ const EnvironmentEditor = function ({
const containingEnvs = allEnvironments.filter((e) =>
e.variables.some((v) => v.name === name),
);
const isAlreadyInActive = containingEnvs.find((e) => e.id === activeEnvironment.id);
const isAlreadyInActive = containingEnvs.find((e) => e.id === selectedEnvironment.id);
if (isAlreadyInActive) continue;
options.push({
label: name,
@@ -204,7 +203,7 @@ const EnvironmentEditor = function ({
});
}
return { options };
}, [activeEnvironment.base, activeEnvironment.id, allEnvironments]);
}, [selectedEnvironment.base, selectedEnvironment.id, allEnvironments]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet and is unusable
@@ -217,11 +216,11 @@ const EnvironmentEditor = function ({
if (!isEncryptionEnabled) {
return true;
} else {
return !activeEnvironment.variables.every(
return !selectedEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
);
}
}, [activeEnvironment.variables, isEncryptionEnabled]);
}, [selectedEnvironment.variables, isEncryptionEnabled]);
const encryptEnvironment = (environment: Environment) => {
withEncryptionEnabled(async () => {
@@ -238,10 +237,10 @@ const EnvironmentEditor = function ({
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<Heading className="w-full flex items-center gap-0.5">
<div className="mr-2">{activeEnvironment?.name}</div>
<div className="mr-2">{selectedEnvironment?.name}</div>
{isEncryptionEnabled ? (
promptToEncrypt ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(activeEnvironment)}>
<BadgeButton color="notice" onClick={() => encryptEnvironment(selectedEnvironment)}>
Encrypt All Variables
</BadgeButton>
) : (
@@ -257,9 +256,9 @@ const EnvironmentEditor = function ({
</>
)}
</Heading>
{activeEnvironment.public && promptToEncrypt && (
{selectedEnvironment.public && promptToEncrypt && (
<DismissibleBanner
id={`warn-unencrypted-${activeEnvironment.id}`}
id={`warn-unencrypted-${selectedEnvironment.id}`}
color="notice"
className="mr-3"
>
@@ -277,11 +276,15 @@ const EnvironmentEditor = function ({
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteFunctions
forcedEnvironmentId={activeEnvironment.id}
forceUpdateKey={`${activeEnvironment.id}::${forceUpdateKey}`}
pairs={activeEnvironment.variables}
forceUpdateKey={`${selectedEnvironment.id}::${forceUpdateKey}`}
pairs={selectedEnvironment.variables}
onChange={handleChange}
stateKey={`environment.${activeEnvironment.id}`}
stateKey={`environment.${selectedEnvironment.id}`}
forcedEnvironmentId={
// Editing the base environment should resolve variables using the active environment.
// Editing a sub environment should resolve variables as if it's the active environment
selectedEnvironment.base ? undefined : selectedEnvironment.id
}
/>
</div>
</VStack>

View File

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

View File

@@ -1,9 +1,8 @@
import { settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { HTMLAttributes, ReactNode } from 'react';
import React from 'react';
import { useOsInfo } from '../hooks/useOsInfo';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import React, { useMemo } from 'react';
import { useStoplightsVisible } from '../hooks/useStoplightsVisible';
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from '../lib/constants';
import { WindowControls } from './WindowControls';
@@ -23,27 +22,42 @@ export function HeaderSize({
onlyXWindowControl,
children,
}: HeaderSizeProps) {
const osInfo = useOsInfo();
const settings = useAtomValue(settingsAtom);
const stoplightsVisible = useStoplightsVisible();
const finalStyle = useMemo<CSSProperties>(() => {
const s = { ...style };
// Set the height (use min-height because scaling font size may make it larger
if (size === 'md') s.minHeight = HEADER_SIZE_MD;
if (size === 'lg') s.minHeight = HEADER_SIZE_LG;
// Add large padding for window controls
if (stoplightsVisible && !ignoreControlsSpacing) {
s.paddingLeft = 72 / settings.interfaceScale;
} else if (!stoplightsVisible && !ignoreControlsSpacing && !settings.hideWindowControls) {
s.paddingRight = WINDOW_CONTROLS_WIDTH;
}
return s;
}, [
ignoreControlsSpacing,
settings.hideWindowControls,
settings.interfaceScale,
size,
stoplightsVisible,
style,
]);
return (
<div
data-tauri-drag-region
style={{
...style,
// Add padding for macOS stoplights, but keep it the same width (account for the interface scale)
paddingLeft:
stoplightsVisible && !ignoreControlsSpacing ? 72 / settings.interfaceScale : undefined,
...(size === 'md' ? { minHeight: HEADER_SIZE_MD } : {}),
...(size === 'lg' ? { minHeight: HEADER_SIZE_LG } : {}),
...(osInfo.osType === 'macos' || ignoreControlsSpacing
? { paddingRight: '2px' }
: { paddingLeft: '2px', paddingRight: WINDOW_CONTROLS_WIDTH }),
}}
style={finalStyle}
className={classNames(
className,
'px-1', // Give it some space on either end
'pt-[1px]', // Make up for bottom border
'select-none relative',
'pt-[1px] w-full border-b border-border-subtle min-w-0',
'w-full border-b border-border-subtle min-w-0',
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}

View File

@@ -1,12 +1,11 @@
import type { ReactNode } from 'react';
import { useAppInfo } from '../hooks/useAppInfo';
import { appInfo } from '../lib/appInfo';
interface Props {
children: ReactNode;
}
export function IsDev({ children }: Props) {
const appInfo = useAppInfo();
if (!appInfo.isDev) {
return null;
}

View File

@@ -2,7 +2,7 @@ import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license';
import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../hooks/useAppInfo';
import { appInfo } from '../lib/appInfo';
import { useLicenseConfirmation } from '../hooks/useLicenseConfirmation';
import { BadgeButton } from './core/BadgeButton';
import type { ButtonProps } from './core/Button';

View File

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

View File

@@ -1,9 +1,9 @@
import { useSearch } from '@tanstack/react-router';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import React, { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { useOsInfo } from '../../hooks/useOsInfo';
import { capitalize } from '../../lib/capitalize';
import { HStack } from '../core/Stacks';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
@@ -27,7 +27,6 @@ const tabs = [TAB_GENERAL, TAB_APPEARANCE, TAB_PROXY, TAB_PLUGINS, TAB_LICENSE]
export type SettingsTab = (typeof tabs)[number];
export default function Settings({ hide }: Props) {
const osInfo = useOsInfo();
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
const [tab, setTab] = useState<string | undefined>(tabFromQuery);
@@ -60,9 +59,7 @@ export default function Settings({ hide }: Props) {
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(osInfo?.osType === 'macos' ? 'text-center' : 'pl-2')}>
Settings
</div>
<div className={classNames(type() === 'macos' ? 'text-center' : 'pl-2')}>Settings</div>
</HStack>
</HeaderSize>
)}

View File

@@ -18,6 +18,7 @@ import type { SelectProps } from '../core/Select';
import { Select } from '../core/Select';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
import { type } from '@tauri-apps/plugin-os';
const fontSizeOptions = [
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
@@ -122,6 +123,15 @@ export function SettingsAppearance() {
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
/>
{type() !== 'macos' && (
<Checkbox
checked={settings.hideWindowControls}
title="Hide Window Controls"
help="Hide the close/maximize/minimize controls on Windows or Linux"
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
/>
)}
<Separator className="my-4" />
<Select

View File

@@ -3,7 +3,7 @@ import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import React from 'react';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useAppInfo } from '../../hooks/useAppInfo';
import { appInfo } from '../../lib/appInfo';
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';
import { revealInFinderText } from '../../lib/reveal';
import { Checkbox } from '../core/Checkbox';
@@ -18,7 +18,6 @@ import { VStack } from '../core/Stacks';
export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const appInfo = useAppInfo();
const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) {

View File

@@ -2,7 +2,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import { useLicense } from '@yaakapp-internal/license';
import { useRef } from 'react';
import { openSettings } from '../commands/openSettings';
import { useAppInfo } from '../hooks/useAppInfo';
import { appInfo } from '../lib/appInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
@@ -17,7 +17,6 @@ import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
export function SettingsDropdown() {
const importData = useImportData();
const exportData = useExportData();
const appInfo = useAppInfo();
const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates();
const { check } = useLicense();

View File

@@ -1,8 +1,10 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import { settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import React, { useState } from 'react';
import { useOsInfo } from '../hooks/useOsInfo';
import {WINDOW_CONTROLS_WIDTH} from "../lib/constants";
import { WINDOW_CONTROLS_WIDTH } from '../lib/constants';
import { Button } from './core/Button';
import { HStack } from './core/Stacks';
@@ -14,10 +16,9 @@ interface Props {
export function WindowControls({ className, onlyX }: Props) {
const [maximized, setMaximized] = useState<boolean>(false);
const osInfo = useOsInfo();
// Never show controls on macOS
if (osInfo.osType === 'macos') {
const settings = useAtomValue(settingsAtom);
// Never show controls on macOS or if hideWindowControls is true
if (type() === 'macos' || settings.hideWindowControls) {
return null;
}

View File

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

View File

@@ -31,10 +31,10 @@ export function Checkbox({
<HStack
as="label"
alignItems="center"
space={3}
space={2}
className={classNames(className, 'text-text mr-auto')}
>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex mr-0.5')}>
<input
aria-hidden
className={classNames(

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey } from '../../hooks/useHotKey';
import { useOsInfo } from '../../hooks/useOsInfo';
import { HStack } from './Stacks';
interface Props {
@@ -11,9 +10,8 @@ interface Props {
}
export function HotKey({ action, className, variant }: Props) {
const osInfo = useOsInfo();
const labelParts = useFormattedHotkey(action);
if (labelParts === null || osInfo == null) {
if (labelParts === null) {
return null;
}

View File

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

View File

@@ -1,6 +1,7 @@
import { Link as RouterLink } from '@tanstack/react-router';
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { appInfo } from '../../lib/appInfo';
import { Icon } from './Icon';
interface Props extends HTMLAttributes<HTMLAnchorElement> {
@@ -13,9 +14,15 @@ export function Link({ href, children, className, ...other }: Props) {
className = classNames(className, 'relative underline hover:text-violet-600');
if (isExternal) {
let finalHref = href;
if (href.startsWith('https://yaak.app')) {
const url = new URL(href);
url.searchParams.set('ref', appInfo.identifier);
finalHref = url.toString();
}
return (
<a
href={href}
href={finalHref}
target="_blank"
rel="noopener noreferrer"
className={classNames(className, 'pr-4 inline-flex items-center')}

View File

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

View File

@@ -1,13 +1,13 @@
import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import { useState } from 'react';
import { useOsInfo } from '../../hooks/useOsInfo';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import { Label } from './Label';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
import { HStack } from './Stacks';
import { type } from '@tauri-apps/plugin-os';
export interface SelectProps<T extends string> {
name: string;
@@ -40,7 +40,6 @@ export function Select<T extends string>({
defaultValue,
size = 'md',
}: SelectProps<T>) {
const osInfo = useOsInfo();
const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`;
const isInvalidSelection = options.find((o) => 'value' in o && o.value === value) == null;
@@ -63,7 +62,7 @@ export function Select<T extends string>({
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName}>
{label}
</Label>
{osInfo?.osType === 'macos' ? (
{type() === 'macos' ? (
<HStack
space={2}
className={classNames(

View File

@@ -116,6 +116,11 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
body = formattedBody.data;
}
// Decode unicode sequences in the text to readable characters
if (pretty) {
body = decodeUnicodeLiterals(body);
}
return (
<Editor
readOnly
@@ -128,3 +133,12 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
/>
);
}
/** Convert \uXXXX to actual Unicode characters */
function decodeUnicodeLiterals(text: string): string {
const decoded = text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
const charCode = parseInt(hex, 16);
return String.fromCharCode(charCode);
});
return decoded;
}

View File

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

View File

@@ -1,15 +0,0 @@
import { invokeCmd } from '../lib/tauri';
export interface AppInfo {
isDev: boolean;
version: string;
name: string;
appDataDir: string;
appLogDir: string;
}
export const appInfo = (await invokeCmd('cmd_metadata')) as AppInfo;
export function useAppInfo() {
return appInfo;
}

View File

@@ -1,13 +1,11 @@
import { useMutation } from '@tanstack/react-query';
import { InlineCode } from '../components/core/InlineCode';
import { showAlert } from '../lib/alert';
import { appInfo } from '../lib/appInfo';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import { invokeCmd } from '../lib/tauri';
import { useAppInfo } from './useAppInfo';
export function useCheckForUpdates() {
const appInfo = useAppInfo();
return useMutation({
mutationKey: ['check_for_updates'],
mutationFn: async () => {

View File

@@ -1,50 +0,0 @@
import type { Environment } from '@yaakapp-internal/models';
import { createWorkspaceModel } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
import { useFastMutation } from './useFastMutation';
export function useCreateEnvironment() {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
return useFastMutation<string | null, unknown, Environment | null>({
mutationKey: ['create_environment', workspaceId],
mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) {
throw new Error('No base environment passed');
}
if (workspaceId == null) {
throw new Error('Cannot create environment when no active workspace');
}
const name = await showPrompt({
id: 'new-environment',
title: 'New Environment',
description: 'Create multiple environments with different sets of variables',
label: 'Name',
placeholder: 'My Environment',
defaultValue: 'My Environment',
confirmText: 'Create',
});
if (name == null) return null;
return createWorkspaceModel({
model: 'environment',
name,
variables: [],
workspaceId,
base: false,
});
},
onSuccess: async (environmentId) => {
if (environmentId == null) {
return; // Was not created
}
setWorkspaceSearchParams({ environment_id: environmentId });
},
});
}

View File

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

View File

@@ -2,7 +2,6 @@ import { type } from '@tauri-apps/plugin-os';
import { debounce } from '@yaakapp-internal/lib';
import { useEffect, useRef } from 'react';
import { capitalize } from '../lib/capitalize';
import { useOsInfo } from './useOsInfo';
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
@@ -176,13 +175,12 @@ export function useHotKeyLabel(action: HotkeyAction): string {
}
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const osInfo = useOsInfo();
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
if (trigger == null || osInfo == null) {
if (trigger == null) {
return null;
}
const os = osInfo.osType;
const os = type();
const parts = trigger.split('+');
const labelParts: string[] = [];

View File

@@ -1,5 +0,0 @@
import { type } from '@tauri-apps/plugin-os';
export function useOsInfo() {
return { osType: type() };
}

View File

@@ -62,6 +62,11 @@ export function useGrpcEvents(connectionId: string | null) {
const events = useAtomValue(grpcEventsAtom);
useEffect(() => {
if (connectionId == null) {
replaceModelsInStore('grpc_event', []);
return;
}
invoke<GrpcEvent[]>('plugin:yaak-models|grpc_events', { connectionId }).then((events) => {
replaceModelsInStore('grpc_event', events);
});

View File

@@ -1,6 +1,10 @@
import { invoke } from '@tauri-apps/api/core';
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
import { replaceModelsInStore , websocketConnectionsAtom, websocketEventsAtom } from '@yaakapp-internal/models';
import {
replaceModelsInStore,
websocketConnectionsAtom,
websocketEventsAtom,
} from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
@@ -45,6 +49,11 @@ export function useWebsocketEvents(connectionId: string | null) {
const events = useAtomValue(websocketEventsAtom);
useEffect(() => {
if (connectionId == null) {
replaceModelsInStore('websocket_event', []);
return;
}
invoke<WebsocketEvent[]>('plugin:yaak-models|websocket_events', { connectionId }).then(
(events) => replaceModelsInStore('websocket_event', events),
);

View File

@@ -1,9 +1,8 @@
import { type } from '@tauri-apps/plugin-os';
import { useIsFullscreen } from './useIsFullscreen';
import { useOsInfo } from './useOsInfo';
export function useStoplightsVisible() {
const platform = useOsInfo();
const fullscreen = useIsFullscreen();
const stoplightsVisible = platform?.osType === 'macos' && !fullscreen;
const stoplightsVisible = type() === 'macos' && !fullscreen;
return stoplightsVisible;
}

View File

@@ -1,11 +1,11 @@
import { setWindowTitle } from '@yaakapp-internal/mac-window';
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { appInfo } from '../lib/appInfo';
import { resolvedModelName } from '../lib/resolvedModelName';
import { useActiveEnvironment } from './useActiveEnvironment';
import { activeRequestAtom } from './useActiveRequest';
import { activeWorkspaceAtom } from './useActiveWorkspace';
import { appInfo } from './useAppInfo';
export function useSyncWorkspaceRequestTitle() {
const activeWorkspace = useAtomValue(activeWorkspaceAtom);

16
src-web/lib/appInfo.ts Normal file
View File

@@ -0,0 +1,16 @@
import { getIdentifier } from '@tauri-apps/api/app';
import { invokeCmd } from './tauri';
export interface AppInfo {
isDev: boolean;
version: string;
name: string;
appDataDir: string;
appLogDir: string;
identifier: string;
}
export const appInfo = {
...(await invokeCmd('cmd_metadata')),
identifier: await getIdentifier(),
} as AppInfo;

View File

@@ -1,5 +1,5 @@
export const HEADER_SIZE_MD = '27px';
export const HEADER_SIZE_LG = '38px';
export const HEADER_SIZE_LG = '40px';
export const WINDOW_CONTROLS_WIDTH = '10.5rem';

View File

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

View File

@@ -88,7 +88,7 @@
"postcss": "^8.4.45",
"postcss-nesting": "^13.0.0",
"tailwindcss": "^3.4.10",
"vite": "6.2.6",
"vite": "6.2.7",
"vite-plugin-static-copy": "^2.2.0",
"vite-plugin-svgr": "^4.3.0",
"vite-plugin-top-level-await": "^1.5.0",

View File

@@ -11,9 +11,9 @@ import { Dialogs } from '../components/Dialogs';
import { GlobalHooks } from '../components/GlobalHooks';
import RouteError from '../components/RouteError';
import { Toasts } from '../components/Toasts';
import { useOsInfo } from '../hooks/useOsInfo';
import { jotaiStore } from '../lib/jotai';
import { queryClient } from '../lib/queryClient';
import { type } from '@tauri-apps/plugin-os';
export const Route = createRootRoute({
component: RouteComponent,
@@ -21,7 +21,6 @@ export const Route = createRootRoute({
});
function RouteComponent() {
const osInfo = useOsInfo();
return (
<JotaiProvider store={jotaiStore}>
<QueryClientProvider client={queryClient}>
@@ -36,7 +35,7 @@ function RouteComponent() {
<div
className={classNames(
'w-full h-full',
osInfo?.osType === 'linux' && 'border border-border-subtle',
type() === 'linux' && 'border border-border-subtle',
)}
>
<Outlet />