mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-02 08:37:18 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6777003b70 | ||
|
|
426c6d5eb6 | ||
|
|
068afe325e | ||
|
|
cef1129d4b | ||
|
|
9a7bcf73bb | ||
|
|
aa76d501f0 | ||
|
|
ce8cb5e7bc |
Generated
+3
@@ -11723,7 +11723,10 @@ name = "yaak-system-appearance"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dark-light",
|
"dark-light",
|
||||||
|
"dispatch2",
|
||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation 0.3.1",
|
||||||
"tauri",
|
"tauri",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
|||||||
)}
|
)}
|
||||||
// If no environments, the button simply opens the dialog.
|
// If no environments, the button simply opens the dialog.
|
||||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
onClick={
|
||||||
|
subEnvironments.length === 0
|
||||||
|
? (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
editEnvironment(null);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
UpdateResponse,
|
UpdateResponse,
|
||||||
YaakNotification,
|
YaakNotification,
|
||||||
} from "@yaakapp-internal/tauri-client";
|
} from "@yaakapp-internal/tauri-client";
|
||||||
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { openSettings } from "../commands/openSettings";
|
import { openSettings } from "../commands/openSettings";
|
||||||
import { Button } from "../components/core/Button";
|
import { Button } from "../components/core/Button";
|
||||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||||
@@ -180,9 +180,65 @@ function showUpdateInstalledToast(version: string) {
|
|||||||
|
|
||||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||||
const UPDATE_TOAST_ID = "update-info";
|
const UPDATE_TOAST_ID = "update-info";
|
||||||
const { version, replyEventId, downloaded } = updateInfo;
|
const { version, replyEventId, downloaded, install } = updateInfo;
|
||||||
|
|
||||||
jotaiStore.set(updateAvailableAtom, { version, downloaded });
|
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
|
||||||
|
|
||||||
|
const whatsNewButton = (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="info"
|
||||||
|
variant="border"
|
||||||
|
rightSlot={<Icon icon="external_link" />}
|
||||||
|
onClick={async () => {
|
||||||
|
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
What's New
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (install !== "integrated") {
|
||||||
|
// Nothing to reply to here; the backend only told us so we can say how to update
|
||||||
|
const flatpak = install === "flatpak";
|
||||||
|
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">
|
||||||
|
{flatpak ? (
|
||||||
|
<>
|
||||||
|
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Download the new version to upgrade."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
action: () => (
|
||||||
|
<HStack space={1.5}>
|
||||||
|
{!flatpak && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="info"
|
||||||
|
rightSlot={<Icon icon="external_link" />}
|
||||||
|
onClick={async () => {
|
||||||
|
await platform.openUrl("https://yaak.app/download");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{whatsNewButton}
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||||
@@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
|||||||
>
|
>
|
||||||
{downloaded ? "Install Now" : "Download and Install"}
|
{downloaded ? "Install Now" : "Download and Install"}
|
||||||
</ButtonInfiniteLoading>
|
</ButtonInfiniteLoading>
|
||||||
<Button
|
{whatsNewButton}
|
||||||
size="xs"
|
|
||||||
color="info"
|
|
||||||
variant="border"
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
onClick={async () => {
|
|
||||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
What's New
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
+28
-6
@@ -1,15 +1,37 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string };
|
||||||
|
|
||||||
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
|
||||||
|
|
||||||
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
|
export type UpdateInfo = {
|
||||||
|
replyEventId: string;
|
||||||
|
version: string;
|
||||||
|
downloaded: boolean;
|
||||||
|
/**
|
||||||
|
* How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||||
|
* itself and the user is told how to update instead.
|
||||||
|
*/
|
||||||
|
install: UpdateInstall;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };
|
/**
|
||||||
|
* How an update can be applied to this install.
|
||||||
|
*/
|
||||||
|
export type UpdateInstall = "integrated" | "flatpak" | "manual";
|
||||||
|
|
||||||
|
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
|
||||||
|
|
||||||
export type UpdateResponseAction = "install" | "skip";
|
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 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, };
|
export type YaakNotificationAction = { label: string; url: string };
|
||||||
|
|||||||
@@ -1367,6 +1367,16 @@ pub fn run() {
|
|||||||
debug!("Launched Yaak {:?}", info);
|
debug!("Launched Yaak {:?}", info);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
RunEvent::WindowEvent { event: WindowEvent::ThemeChanged(_), .. } => {
|
||||||
|
// On macOS this is how OS appearance changes arrive: tao observes
|
||||||
|
// AppleInterfaceThemeChangedNotification and emits it for every window
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
if let Some(state) =
|
||||||
|
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
|
||||||
|
{
|
||||||
|
yaak_system_appearance::emit_change(app_handle, &state);
|
||||||
|
}
|
||||||
|
}
|
||||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
if let Some(state) =
|
if let Some(state) =
|
||||||
|
|||||||
@@ -76,14 +76,6 @@ impl YaakUpdater {
|
|||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
update_trigger: UpdateTrigger,
|
update_trigger: UpdateTrigger,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
// Only AppImage supports updates on Linux, so skip if it's not
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
if std::env::var("APPIMAGE").is_err() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = window.db().get_settings();
|
let settings = window.db().get_settings();
|
||||||
let update_key = format!("{:x}", md5::compute(settings.id));
|
let update_key = format!("{:x}", md5::compute(settings.id));
|
||||||
self.last_check = Some(Instant::now());
|
self.last_check = Some(Instant::now());
|
||||||
@@ -130,6 +122,18 @@ impl YaakUpdater {
|
|||||||
Some(update) => {
|
Some(update) => {
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
|
// Only hand the artifact to the updater plugin when this install can
|
||||||
|
// apply it itself; otherwise tell the user how to update instead
|
||||||
|
let install = update_install_method(&update);
|
||||||
|
if install != UpdateInstall::Integrated {
|
||||||
|
info!(
|
||||||
|
"{} available, but this install updates via {install:?}",
|
||||||
|
update.version
|
||||||
|
);
|
||||||
|
notify_external_update(&w, &update, install);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Force native updater if specified (useful if a release broke the UI)
|
// Force native updater if specified (useful if a release broke the UI)
|
||||||
let native_install_mode =
|
let native_install_mode =
|
||||||
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
||||||
@@ -207,6 +211,23 @@ struct UpdateInfo {
|
|||||||
reply_event_id: String,
|
reply_event_id: String,
|
||||||
version: String,
|
version: String,
|
||||||
downloaded: bool,
|
downloaded: bool,
|
||||||
|
/// How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||||
|
/// itself and the user is told how to update instead.
|
||||||
|
install: UpdateInstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an update can be applied to this install.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export, export_to = "index.ts")]
|
||||||
|
enum UpdateInstall {
|
||||||
|
/// The app downloads and installs it itself
|
||||||
|
#[default]
|
||||||
|
Integrated,
|
||||||
|
/// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark)
|
||||||
|
Flatpak,
|
||||||
|
/// Nothing can install it in-app (distro package, Nix, unknown); download by hand
|
||||||
|
Manual,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||||
@@ -272,8 +293,12 @@ async fn start_integrated_update<R: Runtime>(
|
|||||||
let _guard = Unlisten { win: window, id: event_id };
|
let _guard = Unlisten { win: window, id: event_id };
|
||||||
|
|
||||||
// 2) Emit the event now that listener is in place
|
// 2) Emit the event now that listener is in place
|
||||||
let info =
|
let info = UpdateInfo {
|
||||||
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };
|
version: update.version.to_string(),
|
||||||
|
downloaded,
|
||||||
|
install: UpdateInstall::Integrated,
|
||||||
|
reply_event_id: reply_id,
|
||||||
|
};
|
||||||
window
|
window
|
||||||
.emit_to(window.label(), "update_available", &info)
|
.emit_to(window.label(), "update_available", &info)
|
||||||
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
||||||
@@ -306,6 +331,24 @@ async fn start_integrated_update<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the frontend about an update this install can't apply itself, so the user can be
|
||||||
|
/// told how to get it. Unlike the integrated flow, there is nothing to reply to.
|
||||||
|
fn notify_external_update<R: Runtime>(
|
||||||
|
window: &WebviewWindow<R>,
|
||||||
|
update: &Update,
|
||||||
|
install: UpdateInstall,
|
||||||
|
) {
|
||||||
|
let info = UpdateInfo {
|
||||||
|
version: update.version.to_string(),
|
||||||
|
downloaded: false,
|
||||||
|
install,
|
||||||
|
reply_event_id: generate_id(),
|
||||||
|
};
|
||||||
|
if let Err(e) = window.emit_to(window.label(), "update_available", &info) {
|
||||||
|
warn!("Failed to emit update_available: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||||
// If the frontend doesn't respond, fallback to native dialogs
|
// If the frontend doesn't respond, fallback to native dialogs
|
||||||
let confirmed = window
|
let confirmed = window
|
||||||
@@ -376,7 +419,137 @@ fn detect_install_mode() -> Option<&'static str> {
|
|||||||
return Some("nsis");
|
return Some("nsis");
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
None
|
if !cfg!(target_os = "linux") {
|
||||||
|
None
|
||||||
|
} else if is_flatpak() {
|
||||||
|
Some("flatpak")
|
||||||
|
} else {
|
||||||
|
linux_installer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatpak installs (e.g. FlatPark) are updated by flatpak from their remote; the in-app
|
||||||
|
/// updater can't write inside the sandbox and must not try.
|
||||||
|
fn is_flatpak() -> bool {
|
||||||
|
std::env::var_os("FLATPAK_ID").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How Yaak was installed on Linux, as far as the updater plugin can install into it.
|
||||||
|
///
|
||||||
|
/// The bundle type is stamped into the binary by the bundler, but that only says how the
|
||||||
|
/// binary was *packaged*: third-party packages (AUR, Nix, ...) repackage the .deb, and
|
||||||
|
/// letting dpkg/rpm replace those would stomp on another package manager's files. So a
|
||||||
|
/// deb/rpm install also has to be one the package manager actually owns. The AppImage
|
||||||
|
/// updater needs `$APPIMAGE` since that's the file it replaces.
|
||||||
|
fn linux_installer() -> Option<&'static str> {
|
||||||
|
use tauri::utils::{config::BundleType, platform::bundle_type};
|
||||||
|
match bundle_type() {
|
||||||
|
Some(BundleType::Deb) if package_manager_owns_exe("dpkg", "-S") => Some("deb"),
|
||||||
|
Some(BundleType::Rpm) if package_manager_owns_exe("rpm", "-qf") => Some("rpm"),
|
||||||
|
Some(BundleType::Deb) | Some(BundleType::Rpm) => None,
|
||||||
|
_ if std::env::var_os("APPIMAGE").is_some() => Some("appimage"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `cmd query_arg <current exe>` succeeds, i.e. that package manager knows the
|
||||||
|
/// running executable as one of its files. False when the tool isn't installed at all.
|
||||||
|
fn package_manager_owns_exe(cmd: &str, query_arg: &str) -> bool {
|
||||||
|
let Ok(exe) = std::env::current_exe() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
std::process::Command::new(cmd)
|
||||||
|
.arg(query_arg)
|
||||||
|
.arg(&exe)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the artifact the server returned can be applied to this install. On Linux the
|
||||||
|
/// server may hand back a different package format than the one installed, Flatpak can't
|
||||||
|
/// be written from inside the sandbox, and unknown install methods (distro packages,
|
||||||
|
/// Nix, ...) can't be updated in-app at all.
|
||||||
|
fn update_install_method(update: &Update) -> UpdateInstall {
|
||||||
|
// Dev-only override to preview the non-integrated flows on any OS:
|
||||||
|
// YAAK_SIMULATE_INSTALL=flatpak|manual
|
||||||
|
if is_dev() {
|
||||||
|
match std::env::var("YAAK_SIMULATE_INSTALL").as_deref() {
|
||||||
|
Ok("flatpak") => return UpdateInstall::Flatpak,
|
||||||
|
Ok("manual") => return UpdateInstall::Manual,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg!(target_os = "linux") {
|
||||||
|
return UpdateInstall::Integrated;
|
||||||
|
}
|
||||||
|
if is_flatpak() {
|
||||||
|
return UpdateInstall::Flatpak;
|
||||||
|
}
|
||||||
|
if artifact_matches_installer(linux_installer(), update.download_url.path()) {
|
||||||
|
UpdateInstall::Integrated
|
||||||
|
} else {
|
||||||
|
UpdateInstall::Manual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the artifact at `url_path` is in the package format `installer` can install.
|
||||||
|
fn artifact_matches_installer(installer: Option<&str>, url_path: &str) -> bool {
|
||||||
|
let path = url_path.to_ascii_lowercase();
|
||||||
|
match installer {
|
||||||
|
Some("deb") => path.ends_with(".deb"),
|
||||||
|
Some("rpm") => path.ends_with(".rpm"),
|
||||||
|
Some("appimage") => path.ends_with(".appimage") || path.ends_with(".appimage.tar.gz"),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::artifact_matches_installer;
|
||||||
|
|
||||||
|
const BASE: &str = "/mountain-loop/yaak/releases/download/v2026.6.0/";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_package_format_is_installable() {
|
||||||
|
let cases = [
|
||||||
|
("deb", "yaak_2026.6.0_amd64.deb"),
|
||||||
|
("deb", "yaak_2026.6.0_arm64.deb"),
|
||||||
|
("rpm", "yaak-2026.6.0-1.x86_64.rpm"),
|
||||||
|
("rpm", "yaak-2026.6.0-1.aarch64.rpm"),
|
||||||
|
("appimage", "yaak_2026.6.0_amd64.AppImage"),
|
||||||
|
("appimage", "yaak_2026.6.0_amd64.AppImage.tar.gz"),
|
||||||
|
];
|
||||||
|
for (installer, asset) in cases {
|
||||||
|
assert!(
|
||||||
|
artifact_matches_installer(Some(installer), &format!("{BASE}{asset}")),
|
||||||
|
"{installer} should install {asset}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mismatched_package_format_is_not_installable() {
|
||||||
|
// What the server returns for every Linux install today
|
||||||
|
let appimage = format!("{BASE}yaak_2026.6.0_amd64.AppImage");
|
||||||
|
assert!(!artifact_matches_installer(Some("deb"), &appimage));
|
||||||
|
assert!(!artifact_matches_installer(Some("rpm"), &appimage));
|
||||||
|
|
||||||
|
let deb = format!("{BASE}yaak_2026.6.0_amd64.deb");
|
||||||
|
assert!(!artifact_matches_installer(Some("rpm"), &deb));
|
||||||
|
assert!(!artifact_matches_installer(Some("appimage"), &deb));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_installer_is_never_installable() {
|
||||||
|
for asset in ["yaak_2026.6.0_amd64.deb", "yaak_2026.6.0_amd64.AppImage"] {
|
||||||
|
assert!(!artifact_matches_installer(None, &format!("{BASE}{asset}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn install_update_maybe_download<R: Runtime>(
|
pub async fn install_update_maybe_download<R: Runtime>(
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dark-light = "2.0.0"
|
dark-light = "2.0.0"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
dispatch2 = "0.3.0"
|
||||||
|
objc2-app-kit = { version = "0.3.1", features = ["NSAppearance", "NSApplication", "NSResponder"] }
|
||||||
|
objc2-foundation = { version = "0.3.1", features = ["NSArray", "NSString", "NSUserDefaults"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Runtime};
|
|||||||
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
||||||
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -47,14 +47,12 @@ pub fn initialization_script(appearance: Appearance) -> String {
|
|||||||
|
|
||||||
/// Detect the appearance the OS prefers, independent of any appearance that has
|
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||||
/// been forced onto app windows (which is what the webview itself reports).
|
/// been forced onto app windows (which is what the webview itself reports).
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn system_appearance() -> Option<Appearance> {
|
pub fn system_appearance() -> Option<Appearance> {
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if let Some(appearance) = gsettings_system_appearance() {
|
if let Some(appearance) = gsettings_system_appearance() {
|
||||||
return Some(appearance);
|
return Some(appearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On macOS this reads AppleInterfaceStyle from the global user defaults
|
|
||||||
match dark_light::detect() {
|
match dark_light::detect() {
|
||||||
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
||||||
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
||||||
@@ -66,11 +64,69 @@ pub fn system_appearance() -> Option<Appearance> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||||
|
/// been forced onto app windows (which is what the webview itself reports).
|
||||||
|
///
|
||||||
|
/// This asks AppKit for the application's effective appearance, the same source tauri
|
||||||
|
/// uses for `window.theme()`, instead of reading `AppleInterfaceStyle` from the user
|
||||||
|
/// defaults: macOS 27 no longer reliably writes that key when dark mode is on, so anything
|
||||||
|
/// reading it sees light mode. Appearances forced per window (yaak-mac-window) don't reach
|
||||||
|
/// `NSApp`, so this is the OS preference.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn system_appearance() -> Option<Appearance> {
|
||||||
|
use objc2_app_kit::{NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication};
|
||||||
|
use objc2_foundation::NSArray;
|
||||||
|
|
||||||
|
// AppKit is main-thread only. Every caller runs there today; this keeps it correct if
|
||||||
|
// one ever doesn't.
|
||||||
|
dispatch2::run_on_main(|mtm| {
|
||||||
|
let app = NSApplication::sharedApplication(mtm);
|
||||||
|
|
||||||
|
// An appearance forced on the whole app (tauri's `set_theme` does this) would make
|
||||||
|
// the effective appearance report the override instead of the OS preference. Nothing
|
||||||
|
// in Yaak does that, but fall back to the user defaults if something ever does.
|
||||||
|
//
|
||||||
|
// SAFETY: Called on the main thread with the shared application
|
||||||
|
if unsafe { app.appearance() }.is_some() {
|
||||||
|
return defaults_appearance();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: The appearance names are AppKit constants that live for the whole process
|
||||||
|
let (dark, light) = unsafe { (NSAppearanceNameDarkAqua, NSAppearanceNameAqua) };
|
||||||
|
let names = NSArray::from_slice(&[dark, light]);
|
||||||
|
let best = app.effectiveAppearance().bestMatchFromAppearancesWithNames(&names)?;
|
||||||
|
|
||||||
|
// SAFETY: Both are valid strings
|
||||||
|
let is_dark = unsafe { best.isEqualToString(dark) };
|
||||||
|
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The appearance macOS persists to the global user defaults. Absent means light, except
|
||||||
|
/// on macOS 27, which stopped reliably writing the key. Only used when the effective
|
||||||
|
/// appearance is forced and can't be trusted.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn defaults_appearance() -> Option<Appearance> {
|
||||||
|
use objc2_foundation::{NSUserDefaults, ns_string};
|
||||||
|
|
||||||
|
// SAFETY: The standard defaults are a process-wide singleton and the key is a valid string
|
||||||
|
let style = unsafe {
|
||||||
|
NSUserDefaults::standardUserDefaults().stringForKey(ns_string!("AppleInterfaceStyle"))
|
||||||
|
};
|
||||||
|
|
||||||
|
// SAFETY: Both are valid strings
|
||||||
|
let is_dark = style.is_some_and(|style| unsafe { style.isEqualToString(ns_string!("Dark")) });
|
||||||
|
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
pub fn system_appearance() -> Option<Appearance> {
|
pub fn system_appearance() -> Option<Appearance> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start tracking the OS appearance. Linux polls for changes. macOS gets them from tauri's
|
||||||
|
/// `WindowEvent::ThemeChanged` (tao observes `AppleInterfaceThemeChangedNotification`), which
|
||||||
|
/// the app forwards to [`emit_change`], so no thread is needed there.
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
||||||
let last_appearance = system_appearance();
|
let last_appearance = system_appearance();
|
||||||
@@ -80,13 +136,19 @@ pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
||||||
let thread_state = state.clone();
|
|
||||||
let _ = std::thread::spawn(move || {
|
#[cfg(target_os = "linux")]
|
||||||
loop {
|
{
|
||||||
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
let thread_state = state.clone();
|
||||||
emit_change(&app_handle, &thread_state);
|
let _ = std::thread::spawn(move || {
|
||||||
}
|
loop {
|
||||||
});
|
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
||||||
|
emit_change(&app_handle, &thread_state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let _ = app_handle;
|
||||||
|
|
||||||
Some(state)
|
Some(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,11 @@
|
|||||||
//! own environment chain before a plugin sees them, or an auth plugin receives
|
//! own environment chain before a plugin sees them, or an auth plugin receives
|
||||||
//! `${[ api_key ]}` where it expected a key.
|
//! `${[ api_key ]}` where it expected a key.
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::Result;
|
||||||
use crate::host::PluginHost;
|
use crate::host::PluginHost;
|
||||||
use crate::render::render_json_value;
|
use crate::render::render_form_values;
|
||||||
use std::collections::HashMap;
|
|
||||||
use yaak_models::models::AnyModel;
|
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
|
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, RenderPurpose,
|
||||||
RenderPurpose,
|
|
||||||
};
|
};
|
||||||
use yaak_rpc_schema::*;
|
use yaak_rpc_schema::*;
|
||||||
use yaak_templates::RenderOptions;
|
use yaak_templates::RenderOptions;
|
||||||
@@ -31,7 +28,7 @@ pub async fn cmd_get_http_authentication_config<H: PluginHost>(
|
|||||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||||
// A config form is being displayed, so a template that cannot resolve
|
// A config form is being displayed, so a template that cannot resolve
|
||||||
// should show as blank rather than refuse to open the form.
|
// should show as blank rather than refuse to open the form.
|
||||||
let values = render_auth_values(
|
let values = render_form_values(
|
||||||
&host,
|
&host,
|
||||||
&req.model,
|
&req.model,
|
||||||
req.environment_id.as_deref(),
|
req.environment_id.as_deref(),
|
||||||
@@ -50,7 +47,7 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// An action actually uses these values, so an unresolvable template is an
|
// An action actually uses these values, so an unresolvable template is an
|
||||||
// error rather than an empty string that would silently authenticate wrong.
|
// error rather than an empty string that would silently authenticate wrong.
|
||||||
let values = render_auth_values(
|
let values = render_form_values(
|
||||||
&host,
|
&host,
|
||||||
&req.model,
|
&req.model,
|
||||||
req.environment_id.as_deref(),
|
req.environment_id.as_deref(),
|
||||||
@@ -63,40 +60,3 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
|||||||
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the form's values against the environment chain the model sits in.
|
|
||||||
///
|
|
||||||
/// The chain depends on where the model lives — a request inherits through its
|
|
||||||
/// folder, a workspace has only its own — so the model is what decides which
|
|
||||||
/// variables are in scope.
|
|
||||||
async fn render_auth_values<H: PluginHost>(
|
|
||||||
host: &H,
|
|
||||||
model: &AnyModel,
|
|
||||||
environment_id: Option<&str>,
|
|
||||||
values: HashMap<String, JsonPrimitive>,
|
|
||||||
purpose: RenderPurpose,
|
|
||||||
options: &RenderOptions,
|
|
||||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
|
||||||
let (workspace_id, folder_id) = match model {
|
|
||||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
|
||||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
|
||||||
other => {
|
|
||||||
return Err(Error::Generic(format!(
|
|
||||||
"Cannot resolve authentication for a {}",
|
|
||||||
other.model()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let environment_chain =
|
|
||||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
|
||||||
|
|
||||||
let cb = host.template_callback(purpose);
|
|
||||||
let rendered =
|
|
||||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
|
||||||
|
|
||||||
Ok(serde_json::from_value(rendered)?)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ pub trait PluginHost: Host {
|
|||||||
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
||||||
|
|
||||||
/// The form a template function wants to show for the given values.
|
/// The form a template function wants to show for the given values.
|
||||||
|
/// `values` arrive already rendered.
|
||||||
fn template_function_config(
|
fn template_function_config(
|
||||||
&self,
|
&self,
|
||||||
function_name: &str,
|
function_name: &str,
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
//! Rendering a template against an environment chain.
|
//! Rendering a template against an environment chain.
|
||||||
//!
|
//!
|
||||||
//! The variables come from the chain, the functions come from the host's
|
//! The variables come from the chain, the functions come from the host's
|
||||||
//! template callback. Neither of these knows which host it is running under —
|
//! template callback. `render_template` and `render_json_value` know nothing
|
||||||
//! that is the whole point of taking the callback as a parameter.
|
//! about which host they run under — that is the whole point of taking the
|
||||||
|
//! callback as a parameter. `render_form_values` sits one level up: resolving
|
||||||
|
//! the chain a model sits in is an ordinary database read, so it takes the
|
||||||
|
//! host and does that read before rendering.
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::host::PluginHost;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use yaak_models::models::Environment;
|
use std::collections::HashMap;
|
||||||
|
use yaak_models::models::{AnyModel, Environment};
|
||||||
use yaak_models::render::make_vars_hashmap;
|
use yaak_models::render::make_vars_hashmap;
|
||||||
|
use yaak_plugins::events::{JsonPrimitive, RenderPurpose};
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||||
|
|
||||||
pub async fn render_template<T: TemplateCallback>(
|
pub async fn render_template<T: TemplateCallback>(
|
||||||
@@ -28,3 +35,40 @@ pub async fn render_json_value<T: TemplateCallback>(
|
|||||||
let vars = &make_vars_hashmap(environment_chain);
|
let vars = &make_vars_hashmap(environment_chain);
|
||||||
render_json_value_raw(value, vars, cb, opt).await
|
render_json_value_raw(value, vars, cb, opt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a config form's values against the environment chain the model sits in.
|
||||||
|
///
|
||||||
|
/// The chain depends on where the model lives — a request inherits through its
|
||||||
|
/// folder, a workspace has only its own — so the model is what decides which
|
||||||
|
/// variables are in scope.
|
||||||
|
pub(crate) async fn render_form_values<H: PluginHost>(
|
||||||
|
host: &H,
|
||||||
|
model: &AnyModel,
|
||||||
|
environment_id: Option<&str>,
|
||||||
|
values: HashMap<String, JsonPrimitive>,
|
||||||
|
purpose: RenderPurpose,
|
||||||
|
options: &RenderOptions,
|
||||||
|
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||||
|
let (workspace_id, folder_id) = match model {
|
||||||
|
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||||
|
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||||
|
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||||
|
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||||
|
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||||
|
other => {
|
||||||
|
return Err(Error::Generic(format!(
|
||||||
|
"Cannot resolve environments for a {}",
|
||||||
|
other.model()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let environment_chain =
|
||||||
|
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||||
|
|
||||||
|
let cb = host.template_callback(purpose);
|
||||||
|
let rendered =
|
||||||
|
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||||
|
|
||||||
|
Ok(serde_json::from_value(rendered)?)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::host::PluginHost;
|
use crate::host::PluginHost;
|
||||||
use crate::render::render_template;
|
use crate::render::{render_form_values, render_template};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||||
RenderPurpose,
|
RenderPurpose,
|
||||||
@@ -56,7 +56,19 @@ pub async fn cmd_template_function_config<H: PluginHost>(
|
|||||||
host: H,
|
host: H,
|
||||||
req: CmdTemplateFunctionConfigReq,
|
req: CmdTemplateFunctionConfigReq,
|
||||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||||
host.template_function_config(&req.function_name, req.values, req.model.id()).await
|
// A config form is being displayed, so a template that cannot resolve
|
||||||
|
// should show as blank rather than refuse to open the form.
|
||||||
|
let values = render_form_values(
|
||||||
|
&host,
|
||||||
|
&req.model,
|
||||||
|
req.environment_id.as_deref(),
|
||||||
|
req.values,
|
||||||
|
RenderPurpose::Preview,
|
||||||
|
&RenderOptions::return_empty(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
host.template_function_config(&req.function_name, values, req.model.id()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cmd_get_themes<H: PluginHost>(
|
pub async fn cmd_get_themes<H: PluginHost>(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use yaak_commands::models::{
|
|||||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||||
models_workspace_models,
|
models_workspace_models,
|
||||||
};
|
};
|
||||||
use yaak_commands::templates::cmd_render_template;
|
use yaak_commands::templates::{cmd_render_template, cmd_template_function_config};
|
||||||
use yaak_commands::{Host, PluginHost};
|
use yaak_commands::{Host, PluginHost};
|
||||||
use yaak_core::WorkspaceContext;
|
use yaak_core::WorkspaceContext;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
@@ -172,6 +172,8 @@ struct SingleThreadedHost {
|
|||||||
/// The values the last auth-config call arrived with, so a test can check
|
/// The values the last auth-config call arrived with, so a test can check
|
||||||
/// they were rendered before the host ever saw them.
|
/// they were rendered before the host ever saw them.
|
||||||
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||||
|
/// Same, for the last template-function-config call.
|
||||||
|
fn_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Host for SingleThreadedHost {
|
impl Host for SingleThreadedHost {
|
||||||
@@ -261,9 +263,10 @@ impl PluginHost for SingleThreadedHost {
|
|||||||
async fn template_function_config(
|
async fn template_function_config(
|
||||||
&self,
|
&self,
|
||||||
function_name: &str,
|
function_name: &str,
|
||||||
_values: HashMap<String, JsonPrimitive>,
|
values: HashMap<String, JsonPrimitive>,
|
||||||
_model_id: &str,
|
_model_id: &str,
|
||||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||||
|
*self.fn_values.borrow_mut() = Some(values);
|
||||||
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +379,7 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
|||||||
let host = SingleThreadedHost {
|
let host = SingleThreadedHost {
|
||||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||||
auth_values: Rc::new(RefCell::new(None)),
|
auth_values: Rc::new(RefCell::new(None)),
|
||||||
|
fn_values: Rc::new(RefCell::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
||||||
@@ -448,6 +452,7 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
|||||||
let host = SingleThreadedHost {
|
let host = SingleThreadedHost {
|
||||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||||
auth_values: Rc::new(RefCell::new(None)),
|
auth_values: Rc::new(RefCell::new(None)),
|
||||||
|
fn_values: Rc::new(RefCell::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let workspace = host
|
let workspace = host
|
||||||
@@ -499,3 +504,65 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
|||||||
seen.get("password"),
|
seen.get("password"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same contract as auth: template function argument values may contain
|
||||||
|
/// templates (the 1Password token argument defaults to `${[1PASSWORD_TOKEN]}`),
|
||||||
|
/// and the shared handler renders them before the host is called.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn template_function_values_are_rendered_before_the_host_sees_them() {
|
||||||
|
let TestHost { inner } = TestHost::new();
|
||||||
|
let host = SingleThreadedHost {
|
||||||
|
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||||
|
auth_values: Rc::new(RefCell::new(None)),
|
||||||
|
fn_values: Rc::new(RefCell::new(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let workspace = host
|
||||||
|
.db()
|
||||||
|
.upsert_workspace(
|
||||||
|
&Workspace { name: "Functions".to_string(), ..Default::default() },
|
||||||
|
&host.update_source(),
|
||||||
|
)
|
||||||
|
.expect("workspace");
|
||||||
|
host.db()
|
||||||
|
.upsert_environment(
|
||||||
|
&Environment {
|
||||||
|
workspace_id: workspace.id.clone(),
|
||||||
|
name: "Env".to_string(),
|
||||||
|
variables: vec![EnvironmentVariable {
|
||||||
|
enabled: true,
|
||||||
|
name: "1PASSWORD_TOKEN".to_string(),
|
||||||
|
value: "ops_abc123".to_string(),
|
||||||
|
id: None,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&host.update_source(),
|
||||||
|
)
|
||||||
|
.expect("environment");
|
||||||
|
let environment =
|
||||||
|
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||||
|
|
||||||
|
let mut values = HashMap::new();
|
||||||
|
values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string()));
|
||||||
|
|
||||||
|
// The host refuses the call itself — it has no plugins — but only after the
|
||||||
|
// handler has rendered and handed over the values, which is what matters.
|
||||||
|
let _ = cmd_template_function_config(
|
||||||
|
host.clone(),
|
||||||
|
yaak_rpc_schema::CmdTemplateFunctionConfigReq {
|
||||||
|
function_name: "1password.item".to_string(),
|
||||||
|
values,
|
||||||
|
model: AnyModel::Workspace(workspace),
|
||||||
|
environment_id: Some(environment.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let seen = host.fn_values.borrow().clone().expect("the host should have been called");
|
||||||
|
assert!(
|
||||||
|
matches!(seen.get("token"), Some(JsonPrimitive::String(v)) if v == "ops_abc123"),
|
||||||
|
"the template should have been rendered before reaching the host, got {:?}",
|
||||||
|
seen.get("token"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yaakcli build",
|
"build": "yaakcli build",
|
||||||
"dev": "yaakcli dev"
|
"dev": "yaakcli dev",
|
||||||
|
"test": "vp test --run tests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"oauth-1.0a": "^2.2.6"
|
"oauth-1.0a": "^2.2.6"
|
||||||
|
|||||||
@@ -202,7 +202,10 @@ function hashFunction(signatureMethod: SigMethod) {
|
|||||||
return (base: string, privateKey: string) =>
|
return (base: string, privateKey: string) =>
|
||||||
crypto.createSign("RSA-SHA512").update(base).sign(privateKey, "base64");
|
crypto.createSign("RSA-SHA512").update(base).sign(privateKey, "base64");
|
||||||
case signatures.PLAINTEXT:
|
case signatures.PLAINTEXT:
|
||||||
return (base: string) => base;
|
// RFC 5849 3.4.4: the PLAINTEXT signature IS the signing key,
|
||||||
|
// `encoded(consumer secret)&encoded(token secret)`. Returning the base
|
||||||
|
// string put the whole percent-encoded request into oauth_signature.
|
||||||
|
return (_base: string, key: string) => key;
|
||||||
default:
|
default:
|
||||||
return (base: string, key: string) =>
|
return (base: string, key: string) =>
|
||||||
crypto.createHmac("sha1", key).update(base).digest("base64");
|
crypto.createHmac("sha1", key).update(base).digest("base64");
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { plugin } from "../src";
|
||||||
|
|
||||||
|
function sign(values: Record<string, string>): string {
|
||||||
|
const result = plugin.authentication!.onApply!(
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
values,
|
||||||
|
method: "GET",
|
||||||
|
url: "https://api.example.com/resource",
|
||||||
|
} as never,
|
||||||
|
) as { setHeaders: { name: string; value: string }[] };
|
||||||
|
const header = result.setHeaders[0]!.value;
|
||||||
|
const match = header.match(/oauth_signature="([^"]*)"/);
|
||||||
|
return decodeURIComponent(match![1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PLAINTEXT signature", () => {
|
||||||
|
const base = {
|
||||||
|
signatureMethod: "PLAINTEXT",
|
||||||
|
consumerKey: "ck",
|
||||||
|
consumerSecret: "cs",
|
||||||
|
nonce: "abc123",
|
||||||
|
timestamp: "1700000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
// RFC 5849 3.4.4: the PLAINTEXT signature is the signing key itself --
|
||||||
|
// encoded(consumer secret) "&" encoded(token secret) -- not the base string.
|
||||||
|
test("is the signing key, not the signature base string", () => {
|
||||||
|
expect(sign({ ...base, tokenKey: "tk", tokenSecret: "ts" })).toBe("cs&ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the trailing separator when there is no token secret", () => {
|
||||||
|
expect(sign(base)).toBe("cs&");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("percent-encodes reserved characters in the secrets", () => {
|
||||||
|
expect(sign({ ...base, consumerSecret: "c s", tokenKey: "tk", tokenSecret: "t&s" })).toBe(
|
||||||
|
"c%20s&t%26s",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -359,7 +359,9 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
if (typeof p !== "string") {
|
if (typeof p !== "string") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const [name, value] = p.split("=");
|
// splitOnce: a query value may itself contain "=" (a base64 payload, a
|
||||||
|
// nested filter), and only the first one separates name from value.
|
||||||
|
const [name, value] = splitOnce(p, "=");
|
||||||
urlParameters.push({
|
urlParameters.push({
|
||||||
name: name ?? "",
|
name: name ?? "",
|
||||||
value: value ?? "",
|
value: value ?? "",
|
||||||
@@ -475,7 +477,9 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
...((flagsByName.form as string[] | undefined) || []),
|
...((flagsByName.form as string[] | undefined) || []),
|
||||||
...((flagsByName.F as string[] | undefined) || []),
|
...((flagsByName.F as string[] | undefined) || []),
|
||||||
].map((str) => {
|
].map((str) => {
|
||||||
const parts = str.split("=");
|
// splitOnce for the same reason as --url-query above: base64 padding
|
||||||
|
// ("...==") and any value containing "=" must survive intact.
|
||||||
|
const parts = splitOnce(str, "=");
|
||||||
const name = parts[0] ?? "";
|
const name = parts[0] ?? "";
|
||||||
const value = parts[1] ?? "";
|
const value = parts[1] ?? "";
|
||||||
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
|
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
|
||||||
|
|||||||
@@ -942,6 +942,23 @@ describe("importer-curl", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
test("Keeps an = inside a --url-query value", () => {
|
||||||
|
const imported = convertCurl(
|
||||||
|
'curl --url-query "filter=type=book" --url-query "t=eyJhIjoxfQ==" https://yaak.app',
|
||||||
|
);
|
||||||
|
expect(imported.resources.httpRequests?.[0]?.urlParameters).toEqual([
|
||||||
|
{ enabled: true, name: "filter", value: "type=book" },
|
||||||
|
{ enabled: true, name: "t", value: "eyJhIjoxfQ==" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Keeps an = inside a form value", () => {
|
||||||
|
const imported = convertCurl('curl -F "t=eyJhIjoxfQ==" -F "q=a=b" https://yaak.app');
|
||||||
|
expect(imported.resources.httpRequests?.[0]?.body?.form).toEqual([
|
||||||
|
{ enabled: true, name: "t", value: "eyJhIjoxfQ==" },
|
||||||
|
{ enabled: true, name: "q", value: "a=b" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const idCount: Partial<Record<string, number>> = {};
|
const idCount: Partial<Record<string, number>> = {};
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yaakcli build",
|
"build": "yaakcli build",
|
||||||
"dev": "yaakcli dev"
|
"dev": "yaakcli dev",
|
||||||
|
"test": "vp test --run tests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jsonpath-plus": "^10.3.0"
|
"jsonpath-plus": "^10.3.0"
|
||||||
|
|||||||
@@ -85,7 +85,11 @@ export const plugin: PluginDefinition = {
|
|||||||
],
|
],
|
||||||
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
|
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
|
||||||
const input = String(args.values.input ?? "");
|
const input = String(args.values.input ?? "");
|
||||||
return input.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
// JSON.stringify produces a spec-correct string literal: it escapes
|
||||||
|
// the backslash and quote this used to handle, and also the control
|
||||||
|
// characters it did not. Slicing off the surrounding quotes leaves
|
||||||
|
// the escaped inner text this function is meant to emit.
|
||||||
|
return JSON.stringify(input).slice(1, -1);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Context } from "@yaakapp/api";
|
||||||
|
import { describe, expect, it } from "vite-plus/test";
|
||||||
|
import { plugin } from "../src";
|
||||||
|
|
||||||
|
const LF = String.fromCharCode(10);
|
||||||
|
const TAB = String.fromCharCode(9);
|
||||||
|
const CR = String.fromCharCode(13);
|
||||||
|
|
||||||
|
describe("json.escape", () => {
|
||||||
|
const escapeFunction = plugin.templateFunctions?.find((f) => f.name === "json.escape");
|
||||||
|
|
||||||
|
const escape = async (input: string) =>
|
||||||
|
await escapeFunction!.onRender({} as Context, { values: { input } } as never);
|
||||||
|
|
||||||
|
// The point of the function is that the result can be dropped between two
|
||||||
|
// quotes in a JSON document, so that is what these assert.
|
||||||
|
const embeds = (escaped: string | null) => {
|
||||||
|
JSON.parse(`{"k":"${escaped}"}`);
|
||||||
|
return JSON.parse(`{"k":"${escaped}"}`).k;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should exist", () => {
|
||||||
|
expect(escapeFunction).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes a quote", async () => {
|
||||||
|
const input = `say "hi"`;
|
||||||
|
expect(embeds(await escape(input))).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes a backslash", async () => {
|
||||||
|
const input = `a${String.fromCharCode(92)}b`;
|
||||||
|
expect(embeds(await escape(input))).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes a newline", async () => {
|
||||||
|
const input = `line1${LF}line2`;
|
||||||
|
expect(embeds(await escape(input))).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes a tab and a carriage return", async () => {
|
||||||
|
const input = `a${TAB}b${CR}c`;
|
||||||
|
expect(embeds(await escape(input))).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a pretty-printed JSON document", async () => {
|
||||||
|
const input = JSON.stringify({ name: `he said "hi"`, items: [1, 2] }, null, 2);
|
||||||
|
expect(embeds(await escape(input))).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user