diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 03e03994d..00d72a756 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -29,7 +29,7 @@ import { HelperDialog } from "./HelperDialog"; import { SaveDialog } from "./SaveDialog"; import { SavedSearchDialog } from "./SavedSearchDialog"; import { SearchBar } from "./SearchBar"; -import { SettingsWindow, useOpenSettings } from "./Settings"; +import { SettingsDialogFallback, useOpenSettings } from "./Settings"; import { Sidebar } from "./Sidebar"; import { TakeOverBanner } from "./TakeOverBanner"; @@ -175,7 +175,7 @@ export function ProxyLayout() { - + ); diff --git a/apps/yaak-proxy/components/Settings.tsx b/apps/yaak-proxy/components/Settings.tsx index 169b5359c..0904f0ffd 100644 --- a/apps/yaak-proxy/components/Settings.tsx +++ b/apps/yaak-proxy/components/Settings.tsx @@ -13,27 +13,38 @@ import { SettingsGeneral, SettingsInterface, SettingsTheme, + type SettingsTitlebar, + Toasts, } from "@yaakapp-internal/ui"; -import { atom, useAtom, useSetAtom } from "jotai"; -import { useEffect, useMemo, useState } from "react"; +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { settingsValuesAtom } from "../lib/interfaceSettings"; import { openLink, revealPath } from "../lib/opener"; import { proxySettingsView } from "../lib/settings"; -import { getOsType } from "../lib/tauri"; +import { closeWindow, command, currentWindowLabel, getOsType, subscribe } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; +import { + openSettingsVia, + requestedSettingsTab, + SETTINGS_TAB_EVENT, + windowKind, +} from "../lib/windows"; +import { HelperDialog } from "./HelperDialog"; +import { useProxySetup } from "./ProxyStatus"; import { SettingsProxy } from "./SettingsProxy"; export const SETTINGS_TAB_PROXY = "proxy"; -/** The settings window is open, on a tab, or closed */ -export const settingsOpenAtom = atom(null); +/** The settings dialog is open, on a tab, or closed, where Settings has no window of its own */ +const settingsDialogAtom = atom(null); /** * Yaak's settings, shared: its General, Theme, Interface and License tabs, plus the proxy's own - * Proxy tab. The header's gear and `⌘,` open it. + * Proxy tab. The header's gear and `⌘,` open it in a window of its own, or, where the app has + * no second window to give, such as the browser harness, in this dialog. */ -export function SettingsWindow() { - const [tab, setTab] = useAtom(settingsOpenAtom); +export function SettingsDialogFallback() { + const [tab, setTab] = useAtom(settingsDialogAtom); useSettingsHotkey(); return ( ({ + tab: requestedSettingsTab() ?? SETTINGS_TAB_GENERAL, + // Keys the tabs, which take their tab only as they mount + version: 0, + })); + useEffect( + () => + subscribe(SETTINGS_TAB_EVENT, (tab) => + setShown((s) => ({ tab, version: s.version + 1 })), + ), + [], + ); + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + // A dialog over the settings takes its own Escape and stops it here, an editor's popup + // marks it handled, and an open menu closes on it, leaving the window open + if (e.key !== "Escape" || e.defaultPrevented) return; + if (document.querySelector('[role="menu"]') != null) return; + closeWindow().catch(console.error); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + // Reads the system proxy and certificate as the main window's header does, polling while the + // helper awaits approval, so the helper's dialog here sees it given. + useProxySetup(); + const { hideWindowControls } = useAtomValue(settingsValuesAtom); + const titlebar = useMemo( + () => ({ + osType: getOsType(), + hideWindowControls, + // The proxy draws its own title bar always, as its main window does + useNativeTitlebar: false, + interfaceScale: 1, + }), + [hideWindowControls], + ); + + return ( +
+ + + +
+ ); +} + +/** The settings themselves, as the window or the dialog holds them */ +export function SettingsContent({ + defaultTab, + titlebar = null, +}: { + defaultTab: string; + /** The window's title bar; none in the dialog, which has its own frame */ + titlebar?: SettingsTitlebar | null; +}) { const [values, change] = useAtom(settingsValuesAtom); const app = useAppInfo(); const systemAppearance = useSystemAppearance(); @@ -77,7 +148,7 @@ export function SettingsContent({ defaultTab }: { defaultTab: string }) { return ( { const mac = getOsType() === "macos"; function onKeyDown(e: KeyboardEvent) { if (e.key !== "," || e.shiftKey || e.altKey) return; if (mac ? !e.metaKey || e.ctrlKey : !e.ctrlKey || e.metaKey) return; e.preventDefault(); - setOpen((tab) => tab ?? SETTINGS_TAB_GENERAL); + open(null); } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [setOpen]); + }, [open]); } function useAppInfo() { @@ -174,8 +245,21 @@ function useSystemAppearance(): "light" | "dark" { return dark ? "dark" : "light"; } -/** Opens settings on a tab */ +/** + * Opens settings on a tab, General unless another is given. With `null`, opens it on General, + * or brings it forward on the tab it is on. + */ export function useOpenSettings() { - const setOpen = useSetAtom(settingsOpenAtom); - return (tab: string = SETTINGS_TAB_GENERAL) => setOpen(tab); + const setDialog = useSetAtom(settingsDialogAtom); + const showToast = useShowToast(); + return useCallback( + (tab: string | null = SETTINGS_TAB_GENERAL) => { + openSettingsVia(tab, { + kind: windowKind(currentWindowLabel()), + openWindow: (tab) => command("cmd_open_settings", { tab }), + openDialog: (tab) => setDialog((open) => tab ?? open ?? SETTINGS_TAB_GENERAL), + }).catch((err) => showToast({ id: "open-settings", color: "danger", message: String(err) })); + }, + [setDialog, showToast], + ); } diff --git a/apps/yaak-proxy/hooks/useRpcMutation.ts b/apps/yaak-proxy/hooks/useRpcMutation.ts index f6d4229b4..f563500e0 100644 --- a/apps/yaak-proxy/hooks/useRpcMutation.ts +++ b/apps/yaak-proxy/hooks/useRpcMutation.ts @@ -3,16 +3,18 @@ import type { RpcSchema } from "@yaakapp-internal/proxy-lib"; import { minPromiseMillis } from "@yaakapp-internal/ui"; import type { Req, Res } from "../lib/rpc"; import { rpc } from "../lib/rpc"; +import { announceEngineChange } from "../lib/windowSync"; /** - * React Query mutation wrapper for RPC commands. + * React Query mutation wrapper for RPC commands. The app's other windows hear of each one once + * it settles, and read again what it may have changed. */ export function useRpcMutation( cmd: K, opts?: Omit, Error, Req>, "mutationFn">, ) { return useMutation, Error, Req>({ - mutationFn: (payload) => minPromiseMillis(rpc(cmd, payload)), + mutationFn: (payload) => minPromiseMillis(rpc(cmd, payload)).finally(announceEngineChange), ...opts, }); } diff --git a/apps/yaak-proxy/lib/interfaceSettings.ts b/apps/yaak-proxy/lib/interfaceSettings.ts index 0bf77df03..b0a16c4f5 100644 --- a/apps/yaak-proxy/lib/interfaceSettings.ts +++ b/apps/yaak-proxy/lib/interfaceSettings.ts @@ -1,27 +1,59 @@ import type { SettingsPatch, SettingsValues } from "@yaakapp-internal/ui"; -import { atom } from "jotai"; +import { atom, type createStore } from "jotai"; import { readSettingsValues, SETTINGS_VALUES_KEY } from "./settings"; +import { broadcast, currentWindowLabel, subscribe } from "./tauri"; + +type Store = ReturnType; + +/** Sent with the values after a change, so every window of the app takes them */ +const SETTINGS_VALUES_EVENT = "settings_values_changed"; + +interface SettingsValuesChanged { + /** The window they were changed in, which has taken them already */ + from: string; + values: SettingsValues; +} /** The shared settings tabs' values, kept in local storage */ const valuesAtom = atom( readSettingsValues(localStorage.getItem(SETTINGS_VALUES_KEY)), ); -/** Reads the values, and writes a patch of them through to storage and the window */ +/** Takes `next` in this window: the values, the sizes and, if it changed, the appearance */ +const takeValuesAtom = atom(null, (get, set, next: SettingsValues) => { + const prev = get(valuesAtom); + set(valuesAtom, next); + applyFontSizes(next); + if (next.appearance !== prev.appearance) { + // Loaded on demand: the theme module shows the window as it loads + import("./theme").then((m) => m.setChosenAppearance(next.appearance)).catch(console.error); + } +}); + +/** Reads the values, and writes a patch of them through to storage and every window */ export const settingsValuesAtom = atom( (get) => get(valuesAtom), (get, set, patch: SettingsPatch) => { const next = { ...get(valuesAtom), ...patch }; - set(valuesAtom, next); + set(takeValuesAtom, next); localStorage.setItem(SETTINGS_VALUES_KEY, JSON.stringify(next)); - applyFontSizes(next); - if (patch.appearance != null) { - // Loaded on demand: the theme module shows the window as it loads - import("./theme").then((m) => m.setChosenAppearance(next.appearance)).catch(console.error); - } + const message: SettingsValuesChanged = { from: currentWindowLabel(), values: next }; + broadcast(SETTINGS_VALUES_EVENT, message).catch((err) => + console.warn("Failed to send the settings to the other windows", err), + ); }, ); +/** Takes the values another window changes, such as the theme or sizes set in Settings */ +export function syncSettingsValues(store: Store) { + const here = currentWindowLabel(); + subscribe(SETTINGS_VALUES_EVENT, ({ from, values }) => { + if (from === here) return; + // Read as storage is, so a value of the wrong shape falls back to its default + store.set(takeValuesAtom, readSettingsValues(JSON.stringify(values))); + }); +} + /** The interface and editor sizes, the editor's through the variable the editors read */ export function applyFontSizes( values: Pick, diff --git a/apps/yaak-proxy/lib/tauri.ts b/apps/yaak-proxy/lib/tauri.ts index 344d0ad3b..b53365e1a 100644 --- a/apps/yaak-proxy/lib/tauri.ts +++ b/apps/yaak-proxy/lib/tauri.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import { listen as tauriListen } from "@tauri-apps/api/event"; +import { emit as tauriEmit, listen as tauriListen } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { type as tauriOsType } from "@tauri-apps/plugin-os"; import { unsubscribeWhenReady } from "./unsubscribe"; @@ -39,6 +39,21 @@ export function showWindow(): Promise { return getCurrentWebviewWindow().show(); } +/** Close the current webview window. */ +export function closeWindow(): Promise { + return getCurrentWebviewWindow().close(); +} + +/** The current window's label. */ +export function currentWindowLabel(): string { + return getCurrentWebviewWindow().label; +} + +/** Send an event to every window of the app, this one included. */ +export function broadcast(event: string, payload: unknown): Promise { + return tauriEmit(event, payload); +} + /** Get the current OS type (e.g. "macos", "linux", "windows"). */ export function getOsType() { return tauriOsType(); diff --git a/apps/yaak-proxy/lib/windowSync.test.ts b/apps/yaak-proxy/lib/windowSync.test.ts new file mode 100644 index 000000000..6d69c9f66 --- /dev/null +++ b/apps/yaak-proxy/lib/windowSync.test.ts @@ -0,0 +1,77 @@ +import { QueryClient } from "@tanstack/react-query"; +import { createStore } from "jotai"; +import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; + +const tauri = vi.hoisted(() => ({ + handlers: new Map void>(), + sent: [] as { event: string; payload: unknown }[], + label: "main_0", +})); +vi.mock("./tauri", () => ({ + currentWindowLabel: () => tauri.label, + broadcast: async (event: string, payload: unknown) => { + tauri.sent.push({ event, payload }); + }, + subscribe: (event: string, handler: (payload: unknown) => void) => { + tauri.handlers.set(event, handler); + return () => tauri.handlers.delete(event); + }, +})); +// The store's module reaches for the window as it loads; reading the state again is all this needs +const refreshProxyState = vi.hoisted(() => vi.fn<() => Promise>()); +vi.mock("./store", () => ({ refreshProxyState })); + +const { announceEngineChange, isSharedStateQuery, syncFromOtherWindows } = + await import("./windowSync"); + +describe("windowSync", () => { + beforeEach(() => { + tauri.handlers.clear(); + tauri.sent = []; + tauri.label = "main_0"; + refreshProxyState.mockReset().mockResolvedValue(undefined); + }); + + test("a change is announced as coming from this window", () => { + tauri.label = "settings"; + announceEngineChange(); + expect(tauri.sent).toEqual([{ event: "window_changed_engine", payload: { from: "settings" } }]); + }); + + test("a change in another window reads the state and the settings' queries again", () => { + const store = createStore(); + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + syncFromOtherWindows(store, queryClient); + + tauri.handlers.get("window_changed_engine")?.({ from: "settings" }); + + expect(refreshProxyState).toHaveBeenCalledWith(store); + expect(invalidate).toHaveBeenCalledTimes(1); + }); + + test("a window's own change is not read again, as it has been already", () => { + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + syncFromOtherWindows(createStore(), queryClient); + + tauri.handlers.get("window_changed_engine")?.({ from: "main_0" }); + + expect(refreshProxyState).not.toHaveBeenCalled(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + test("only what settings and setup show is read again, not flows or their bodies", () => { + for (const cmd of [ + "system_proxy_status", + "ca_status", + "get_storage_size", + "get_decrypt_list", + ]) { + expect(isSharedStateQuery([cmd, {}])).toBe(true); + } + for (const cmd of ["get_flow_body", "list_flows", "query_schema"]) { + expect(isSharedStateQuery([cmd, {}])).toBe(false); + } + }); +}); diff --git a/apps/yaak-proxy/lib/windowSync.ts b/apps/yaak-proxy/lib/windowSync.ts new file mode 100644 index 000000000..fa1b8e47a --- /dev/null +++ b/apps/yaak-proxy/lib/windowSync.ts @@ -0,0 +1,51 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import type { createStore } from "jotai"; +import { refreshProxyState } from "./store"; +import { broadcast, currentWindowLabel, subscribe } from "./tauri"; + +type Store = ReturnType; + +/** Sent after a change made through the engine, so the app's other windows read again */ +const ENGINE_CHANGED_EVENT = "window_changed_engine"; + +interface EngineChanged { + /** The window the change was made in, which has read again already */ + from: string; +} + +/** + * The queries a change in another window can leave stale: what Settings sets, and the system + * proxy and certificate state the header and Settings both show. Flows and their bodies have + * events of their own, and reading them again would cost far more. + */ +const SHARED_STATE_QUERIES = new Set([ + "system_proxy_status", + "ca_status", + "get_storage_size", + "get_decrypt_list", + "list_seen", +]); + +export function isSharedStateQuery(queryKey: QueryKey): boolean { + return SHARED_STATE_QUERIES.has(String(queryKey[0])); +} + +/** Tells the app's other windows a change was made here, such as a setting */ +export function announceEngineChange() { + broadcast(ENGINE_CHANGED_EVENT, { from: currentWindowLabel() } satisfies EngineChanged).catch( + (err) => console.warn("Failed to tell the other windows of a change", err), + ); +} + +/** + * Reads the engine's state and the shared queries again when another window changes them, as + * that window does itself, so a setting changed in Settings shows in the main window at once. + */ +export function syncFromOtherWindows(store: Store, queryClient: QueryClient) { + const here = currentWindowLabel(); + subscribe(ENGINE_CHANGED_EVENT, ({ from }) => { + if (from === here) return; + refreshProxyState(store).catch((err) => console.warn("Failed to read the proxy state", err)); + void queryClient.invalidateQueries({ predicate: (q) => isSharedStateQuery(q.queryKey) }); + }); +} diff --git a/apps/yaak-proxy/lib/windows.test.ts b/apps/yaak-proxy/lib/windows.test.ts new file mode 100644 index 000000000..05e801e22 --- /dev/null +++ b/apps/yaak-proxy/lib/windows.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { openSettingsVia, requestedSettingsTab, windowKind } from "./windows"; + +describe("windowKind", () => { + test("the Settings window holds the settings", () => { + expect(windowKind("settings")).toBe("settings"); + }); + + test("a main window holds the app", () => { + expect(windowKind("main_0")).toBe("main"); + expect(windowKind("main_3")).toBe("main"); + }); + + test("a page outside the app's windows holds the app on its own", () => { + expect(windowKind("harness")).toBe("page"); + expect(windowKind("settings_0")).toBe("page"); + expect(windowKind("")).toBe("page"); + }); +}); + +describe("openSettingsVia", () => { + function via(kind: Parameters[0]) { + return { + kind: windowKind(kind), + openWindow: vi.fn<(tab: string | null) => Promise>().mockResolvedValue(undefined), + openDialog: vi.fn<(tab: string | null) => void>(), + }; + } + + test("the main window opens Settings in its own window, on the tab asked for", async () => { + const v = via("main_0"); + await openSettingsVia("proxy", v); + expect(v.openWindow).toHaveBeenCalledWith("proxy"); + expect(v.openDialog).not.toHaveBeenCalled(); + }); + + test("with no tab, the window is brought forward where it is", async () => { + const v = via("main_0"); + await openSettingsVia(null, v); + expect(v.openWindow).toHaveBeenCalledWith(null); + }); + + test("a page with no second window opens the dialog instead", async () => { + const v = via("harness"); + await openSettingsVia("theme", v); + expect(v.openDialog).toHaveBeenCalledWith("theme"); + expect(v.openWindow).not.toHaveBeenCalled(); + }); + + test("a window that fails to open says so", async () => { + const v = via("main_0"); + v.openWindow.mockRejectedValue(new Error("no")); + await expect(openSettingsVia("general", v)).rejects.toThrow("no"); + }); +}); + +describe("requestedSettingsTab", () => { + // The page's window, which the script `cmd_open_settings` passes sets the tab on + const w = globalThis as { __YAAK_SETTINGS_TAB__?: unknown }; + afterEach(() => { + delete w.__YAAK_SETTINGS_TAB__; + }); + + test("reads the tab the window was opened on", () => { + w.__YAAK_SETTINGS_TAB__ = "proxy"; + expect(requestedSettingsTab()).toBe("proxy"); + }); + + test("is none when the window was opened on no tab, or something else was set", () => { + expect(requestedSettingsTab()).toBeNull(); + w.__YAAK_SETTINGS_TAB__ = 4; + expect(requestedSettingsTab()).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/windows.ts b/apps/yaak-proxy/lib/windows.ts new file mode 100644 index 000000000..b86862b5c --- /dev/null +++ b/apps/yaak-proxy/lib/windows.ts @@ -0,0 +1,49 @@ +/** The Settings window's label, which `cmd_open_settings` gives it */ +export const SETTINGS_WINDOW_LABEL = "settings"; + +/** The event that moves an open Settings window to another tab */ +export const SETTINGS_TAB_EVENT = "settings_tab"; + +/** Labels the app's main windows start with, as the shared window code names them */ +const MAIN_WINDOW_PREFIX = "main_"; + +/** + * What a page renders, by its window's label: the Settings window holds the settings alone, + * a main window the app. Anything else, such as the browser harness, renders the app in a + * page of its own, with no second window to open. + */ +export type WindowKind = "main" | "settings" | "page"; + +export function windowKind(label: string): WindowKind { + if (label === SETTINGS_WINDOW_LABEL) return "settings"; + if (label.startsWith(MAIN_WINDOW_PREFIX)) return "main"; + return "page"; +} + +/** The app can open Settings in a window of its own, rather than a dialog over this one */ +export function opensWindows(kind: WindowKind): boolean { + return kind !== "page"; +} + +/** How Settings opens: a window of its own where there is one to give, a dialog otherwise */ +export interface OpenSettingsVia { + kind: WindowKind; + /** Opens the Settings window, or brings it forward, on the tab if one is given */ + openWindow: (tab: string | null) => Promise; + /** Opens the dialog on the tab, or keeps it where it is when none is given */ + openDialog: (tab: string | null) => void; +} + +export function openSettingsVia(tab: string | null, via: OpenSettingsVia): Promise { + if (!opensWindows(via.kind)) { + via.openDialog(tab); + return Promise.resolve(); + } + return via.openWindow(tab); +} + +/** The tab `cmd_open_settings` asked a new Settings window to open on, if it asked */ +export function requestedSettingsTab(): string | null { + const tab = (globalThis as { __YAAK_SETTINGS_TAB__?: unknown }).__YAAK_SETTINGS_TAB__; + return typeof tab === "string" ? tab : null; +} diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index f4a86dd70..846ba5405 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -5,19 +5,31 @@ import { HotkeyProvider } from "@yaakapp-internal/ui"; import { type ReactNode, StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ProxyLayout } from "./components/ProxyLayout"; +import { SettingsWindowContent } from "./components/Settings"; import { useHotkeyContextValue } from "./lib/hotkeys"; +import { syncSettingsValues } from "./lib/interfaceSettings"; import { syncNavigation, syncSidebarSelection } from "./lib/navigation"; import { syncFlows, syncProxyState } from "./lib/store"; +import { currentWindowLabel } from "./lib/tauri"; +import { syncFromOtherWindows } from "./lib/windowSync"; +import { windowKind } from "./lib/windows"; import "./main.css"; const queryClient = new QueryClient(); const jotaiStore = createStore(); +// Each window's page is the same; its label says which part of the app it holds +const settingsWindow = windowKind(currentWindowLabel()) === "settings"; syncProxyState(jotaiStore); -// Before the flows load, so they load for the filter it restores -await syncNavigation(jotaiStore); -syncFlows(jotaiStore); -syncSidebarSelection(jotaiStore); +syncSettingsValues(jotaiStore); +syncFromOtherWindows(jotaiStore, queryClient); +// Only the app's window lists traffic and navigates it +if (!settingsWindow) { + // Before the flows load, so they load for the filter it restores + await syncNavigation(jotaiStore); + syncFlows(jotaiStore); + syncSidebarSelection(jotaiStore); +} function Hotkeys({ children }: { children: ReactNode }) { return {children}; @@ -29,13 +41,18 @@ createRoot(document.getElementById("root") as HTMLElement).render( - - - - - - - + + + {settingsWindow ? ( + // The app's hotkeys act on traffic, and their confirmations live in its window + + ) : ( + + + + )} + + , diff --git a/crates-tauri/yaak-app-proxy/src/lib.rs b/crates-tauri/yaak-app-proxy/src/lib.rs index 1f226c46b..6d523a07b 100644 --- a/crates-tauri/yaak-app-proxy/src/lib.rs +++ b/crates-tauri/yaak-app-proxy/src/lib.rs @@ -22,6 +22,12 @@ fn setup_window_menu(win: &WebviewWindow) { #[cfg(not(target_os = "linux"))] win.app_handle().set_menu(menu).expect("Failed to set app menu"); + handle_menu_events(win); +} + +/// Carries out the app menu's items while `win` is focused. A window opened after the main one +/// takes the menu as it is, so the state its items are in, such as Go's, is kept. +fn handle_menu_events(win: &WebviewWindow) { let webview_window = win.clone(); win.on_menu_event(move |w, event| { if !w.is_focused().unwrap() { @@ -82,6 +88,89 @@ fn bundled_protoc(app: &tauri::AppHandle) -> Option String { + let tab = serde_json::to_string(tab).unwrap_or_else(|_| "null".to_string()); + format!("window.__YAAK_SETTINGS_TAB__ = {tab};") +} + +/// Opens Settings in its own window on `tab`, or, when it is open, brings it forward and moves +/// it to `tab` if one is given. +#[tauri::command] +async fn cmd_open_settings( + app_handle: tauri::AppHandle, + tab: Option, +) -> Result<(), String> { + if let Some(win) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL) { + if win.is_minimized().unwrap_or(false) { + win.unminimize().map_err(|e| e.to_string())?; + } + win.set_focus().map_err(|e| e.to_string())?; + if let Some(tab) = tab { + win.emit_to(SETTINGS_WINDOW_LABEL, SETTINGS_TAB_EVENT, tab) + .map_err(|e| e.to_string())?; + } + return Ok(()); + } + + // Over the middle of the main window the first time, as Yaak's; after that, where it was left + let position = app_handle + .get_webview_window(MAIN_WINDOW_LABEL) + .and_then(|main| centered_over(&main, SETTINGS_WINDOW_SIZE).ok()); + let config = CreateWindowConfig { + url: "/", + label: SETTINGS_WINDOW_LABEL, + title: "Yaak Proxy Settings", + inner_size: Some(SETTINGS_WINDOW_SIZE), + position, + initialization_script: tab.as_deref().map(settings_tab_script), + hidden: true, + hide_titlebar: true, + ..Default::default() + }; + let win = yaak_window::window::create_window(&app_handle, config).map_err(|e| e.to_string())?; + let (width, height) = SETTINGS_WINDOW_MIN_SIZE; + win.set_min_size(Some(tauri::LogicalSize::new(width, height))).map_err(|e| e.to_string())?; + handle_menu_events(&win); + Ok(()) +} + +/// Where a window of `size` sits centred over `parent`, in logical pixels. +fn centered_over( + parent: &WebviewWindow, + (width, height): (f64, f64), +) -> tauri::Result<(f64, f64)> { + let scale = parent.scale_factor()?; + let pos = parent.inner_position()?.to_logical::(scale); + let size = parent.inner_size()?.to_logical::(scale); + Ok((pos.x + (size.width - width) / 2.0, pos.y + (size.height - height) / 2.0)) +} + +/// Closing the main window closes Settings with it, which would otherwise keep the app open +/// with nothing to set. +fn close_settings_with(main: &WebviewWindow) { + let app_handle = main.app_handle().clone(); + main.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { .. } = event + && let Some(settings) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL) + && let Err(e) = settings.close() + { + warn!("Failed to close the settings window: {e}"); + } + }); +} + #[tauri::command] async fn rpc( router: State<'_, RpcRouter>, @@ -151,7 +240,7 @@ pub fn run() { Ok(()) }) - .invoke_handler(tauri::generate_handler![rpc, set_go_menu]) + .invoke_handler(tauri::generate_handler![rpc, set_go_menu, cmd_open_settings]) .build(tauri::generate_context!()) .expect("error while building yaak proxy tauri application") .run(|app_handle, event| { @@ -165,7 +254,7 @@ pub fn run() { if let RunEvent::Ready = event { let config = CreateWindowConfig { url: "/", - label: "main_0", + label: MAIN_WINDOW_LABEL, title: "Yaak Proxy", inner_size: Some((1000.0, 700.0)), hidden: true, @@ -173,7 +262,10 @@ pub fn run() { ..Default::default() }; match yaak_window::window::create_window(app_handle, config) { - Ok(win) => setup_window_menu(&win), + Ok(win) => { + setup_window_menu(&win); + close_settings_with(&win); + } Err(e) => error!("Failed to create proxy window: {e:?}"), } } @@ -215,4 +307,39 @@ mod tests { assert!(permissions.contains(&needed), "{needed} missing from {permissions:?}"); } } + + /// The Settings window gets the main window's permissions: without them its links, folders, + /// Escape closing it and the events that keep both windows in step would all be refused. + #[test] + fn the_settings_window_has_the_main_windows_permissions() { + let capability: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/default.json")).unwrap(); + let windows: Vec<&str> = + capability["windows"].as_array().unwrap().iter().filter_map(|w| w.as_str()).collect(); + for label in [MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL] { + assert!( + windows.contains(&"*") || windows.contains(&label), + "{label} missing from {windows:?}" + ); + } + let permissions: Vec<&str> = capability["permissions"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p.as_str()) + .collect(); + for needed in ["core:default", "core:window:allow-close"] { + assert!(permissions.contains(&needed), "{needed} missing from {permissions:?}"); + } + } + + /// The tab reaches the new window's page as a string literal, whatever it holds. + #[test] + fn the_settings_tab_script_quotes_the_tab() { + assert_eq!(settings_tab_script("proxy"), r#"window.__YAAK_SETTINGS_TAB__ = "proxy";"#); + assert_eq!( + settings_tab_script(r#"a";alert(1);""#), + r#"window.__YAAK_SETTINGS_TAB__ = "a\";alert(1);\"";"# + ); + } }