mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Open Yaak Proxy's Settings in its own window
Settings opens in a window labelled `settings`, created through the shared window code, as Yaak's does. The page renders the settings alone or the app by its window label. Opening it again focuses it and moves it to the tab asked for; Escape closes it, and closing the main window closes it too. Changes made there reach the main window at once: theme and sizes by an event carrying the values, and engine settings by an event after each RPC mutation that makes other windows read the proxy state and settings queries again. The browser harness, with no second window, keeps the dialog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca1494b54a
commit
f7402f5f4f
11 files changed
+573
-45
No files matched your search
@@ -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() {
|
||||
<SavedSearchDialog />
|
||||
<AddColumnDialog />
|
||||
<HelperDialog />
|
||||
<SettingsWindow />
|
||||
<SettingsDialogFallback />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
/** The settings dialog is open, on a tab, or closed, where Settings has no window of its own */
|
||||
const settingsDialogAtom = atom<string | null>(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 (
|
||||
<Dialog
|
||||
@@ -49,8 +60,68 @@ export function SettingsWindow() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The settings themselves, as the window holds them */
|
||||
export function SettingsContent({ defaultTab }: { defaultTab: string }) {
|
||||
/**
|
||||
* The Settings window: the settings alone, full-window under their own title bar, on the tab
|
||||
* asked for, moving to another tab each time Settings is opened on one. Escape closes it. The
|
||||
* dialogs the settings open, and their toasts, show over it.
|
||||
*/
|
||||
export function SettingsWindowContent() {
|
||||
const [shown, setShown] = useState(() => ({
|
||||
tab: requestedSettingsTab() ?? SETTINGS_TAB_GENERAL,
|
||||
// Keys the tabs, which take their tab only as they mount
|
||||
version: 0,
|
||||
}));
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe<string>(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<SettingsTitlebar>(
|
||||
() => ({
|
||||
osType: getOsType(),
|
||||
hideWindowControls,
|
||||
// The proxy draws its own title bar always, as its main window does
|
||||
useNativeTitlebar: false,
|
||||
interfaceScale: 1,
|
||||
}),
|
||||
[hideWindowControls],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-surface">
|
||||
<SettingsContent key={shown.version} defaultTab={shown.tab} titlebar={titlebar} />
|
||||
<HelperDialog />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<SettingsDialog
|
||||
titlebar={null}
|
||||
titlebar={titlebar}
|
||||
defaultTab={defaultTab}
|
||||
tabs={[
|
||||
{
|
||||
@@ -115,20 +186,20 @@ export function SettingsContent({ defaultTab }: { defaultTab: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `⌘,`, or `Ctrl+,` off macOS, opens settings, as in Yaak */
|
||||
/** `⌘,`, or `Ctrl+,` off macOS, opens settings, as in Yaak, on the tab they were left on */
|
||||
function useSettingsHotkey() {
|
||||
const setOpen = useSetAtom(settingsOpenAtom);
|
||||
const open = useOpenSettings();
|
||||
useEffect(() => {
|
||||
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<void>("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],
|
||||
);
|
||||
}
|
||||
@@ -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<K extends keyof RpcSchema>(
|
||||
cmd: K,
|
||||
opts?: Omit<UseMutationOptions<Res<K>, Error, Req<K>>, "mutationFn">,
|
||||
) {
|
||||
return useMutation<Res<K>, Error, Req<K>>({
|
||||
mutationFn: (payload) => minPromiseMillis(rpc(cmd, payload)),
|
||||
mutationFn: (payload) => minPromiseMillis(rpc(cmd, payload)).finally(announceEngineChange),
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
@@ -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<typeof createStore>;
|
||||
|
||||
/** 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<SettingsValues>(
|
||||
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<SettingsValuesChanged>(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<SettingsValues, "interfaceFontSize" | "editorFontSize">,
|
||||
|
||||
@@ -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<void> {
|
||||
return getCurrentWebviewWindow().show();
|
||||
}
|
||||
|
||||
/** Close the current webview window. */
|
||||
export function closeWindow(): Promise<void> {
|
||||
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<void> {
|
||||
return tauriEmit(event, payload);
|
||||
}
|
||||
|
||||
/** Get the current OS type (e.g. "macos", "linux", "windows"). */
|
||||
export function getOsType() {
|
||||
return tauriOsType();
|
||||
|
||||
@@ -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<string, (payload: unknown) => 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<void>>());
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createStore>;
|
||||
|
||||
/** 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<EngineChanged>(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) });
|
||||
});
|
||||
}
|
||||
@@ -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<typeof windowKind>[0]) {
|
||||
return {
|
||||
kind: windowKind(kind),
|
||||
openWindow: vi.fn<(tab: string | null) => Promise<void>>().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();
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
/** 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<void> {
|
||||
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;
|
||||
}
|
||||
+28
-11
@@ -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 <HotkeyProvider value={useHotkeyContextValue()}>{children}</HotkeyProvider>;
|
||||
@@ -29,13 +41,18 @@ createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={jotaiStore}>
|
||||
<Hotkeys>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<ProxyLayout />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
</Hotkeys>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
{settingsWindow ? (
|
||||
// The app's hotkeys act on traffic, and their confirmations live in its window
|
||||
<SettingsWindowContent />
|
||||
) : (
|
||||
<Hotkeys>
|
||||
<ProxyLayout />
|
||||
</Hotkeys>
|
||||
)}
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -22,6 +22,12 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) {
|
||||
#[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<R: Runtime>(win: &WebviewWindow<R>) {
|
||||
let webview_window = win.clone();
|
||||
win.on_menu_event(move |w, event| {
|
||||
if !w.is_focused().unwrap() {
|
||||
@@ -82,6 +88,89 @@ fn bundled_protoc<R: Runtime>(app: &tauri::AppHandle<R>) -> Option<yaak_proxy_li
|
||||
.then_some(yaak_proxy_lib::GrpcConfig { protoc_include_dir, protoc_bin_path })
|
||||
}
|
||||
|
||||
/// The main window's label.
|
||||
const MAIN_WINDOW_LABEL: &str = "main_0";
|
||||
/// The Settings window's label, by which its page renders the settings alone.
|
||||
const SETTINGS_WINDOW_LABEL: &str = "settings";
|
||||
/// The event that moves an open Settings window to another tab.
|
||||
const SETTINGS_TAB_EVENT: &str = "settings_tab";
|
||||
/// The Settings window's size, as Yaak's.
|
||||
const SETTINGS_WINDOW_SIZE: (f64, f64) = (750.0, 600.0);
|
||||
/// The smallest the Settings window goes, still showing the tabs beside their content.
|
||||
const SETTINGS_WINDOW_MIN_SIZE: (f64, f64) = (600.0, 450.0);
|
||||
|
||||
/// Sets the tab a new Settings window opens on, before its page loads, so it needs no URL.
|
||||
fn settings_tab_script(tab: &str) -> 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<String>,
|
||||
) -> 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<R: Runtime>(
|
||||
parent: &WebviewWindow<R>,
|
||||
(width, height): (f64, f64),
|
||||
) -> tauri::Result<(f64, f64)> {
|
||||
let scale = parent.scale_factor()?;
|
||||
let pos = parent.inner_position()?.to_logical::<f64>(scale);
|
||||
let size = parent.inner_size()?.to_logical::<f64>(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<R: Runtime>(main: &WebviewWindow<R>) {
|
||||
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<ProxyCtx>>,
|
||||
@@ -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);\"";"#
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user