From 33f32cccf63a8faa02df4a142b220487bbd22443 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 17 Aug 2026 11:29:20 -0700 Subject: [PATCH 1/2] Leave browser zoom to the browser (#578) The web build bound Cmd/Ctrl +, - and 0 and then did nothing with them, so zoom appeared broken. Those keys are the browser's own page zoom, which scales the whole page and remembers it per site. Adds an interfaceZoom capability: true on desktop, where the host zooms the webview, false in a browser. When false the app binds nothing and the hotkeys screen drops the three rows it can't configure. --- .../components/Settings/SettingsHotkeys.tsx | 2 +- apps/yaak-client/hooks/useHotKey.ts | 34 +++++++++++++------ packages/platform/src/tauri/index.ts | 1 + packages/platform/src/types.ts | 5 +++ packages/platform/src/web/README.md | 5 ++- packages/platform/src/web/index.ts | 3 ++ 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/apps/yaak-client/components/Settings/SettingsHotkeys.tsx b/apps/yaak-client/components/Settings/SettingsHotkeys.tsx index 1d8c82f3..14709ec2 100644 --- a/apps/yaak-client/components/Settings/SettingsHotkeys.tsx +++ b/apps/yaak-client/components/Settings/SettingsHotkeys.tsx @@ -124,7 +124,7 @@ export function SettingsHotkeys() { { const newHotkeys = { ...settings.hotkeys }; diff --git a/apps/yaak-client/hooks/useHotKey.ts b/apps/yaak-client/hooks/useHotKey.ts index f0efe8d6..237786b5 100644 --- a/apps/yaak-client/hooks/useHotKey.ts +++ b/apps/yaak-client/hooks/useHotKey.ts @@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => { // Merge default hotkeys with custom hotkeys from settings // Custom hotkeys override defaults for the same action // An empty array means the hotkey is intentionally disabled - const merged: Record = { ...defaultHotkeys }; + const merged: Partial> = {}; + for (const action of hotkeyActions) { + merged[action] = defaultHotkeys[action]; + } for (const [action, keys] of Object.entries(customHotkeys)) { - if (action in defaultHotkeys && Array.isArray(keys)) { + if (action in merged && Array.isArray(keys)) { merged[action as HotkeyAction] = keys; } } @@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => { }); /** Helper function to get current hotkeys from the store */ -function getHotkeys(): Record { +function getHotkeys(): Partial> { return jotaiStore.get(hotkeysAtom); } @@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [ "Space", ]; +/** Zoom is the browser's own on these keys, so the app has no such action there. */ +const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"]; + +/** + * The actions this host actually has. An action left out of here has no keys in + * `hotkeysAtom`, so it never matches and never claims the keystroke. + */ export const hotkeyActions: HotkeyAction[] = ( Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[] -).sort((a, b) => { - const scopeA = a.split(".")[0] || ""; - const scopeB = b.split(".")[0] || ""; - if (scopeA !== scopeB) { - return scopeA.localeCompare(scopeB); - } - return hotkeyLabels[a].localeCompare(hotkeyLabels[b]); -}); +) + .filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a)) + .sort((a, b) => { + const scopeA = a.split(".")[0] || ""; + const scopeB = b.split(".")[0] || ""; + if (scopeA !== scopeB) { + return scopeA.localeCompare(scopeB); + } + return hotkeyLabels[a].localeCompare(hotkeyLabels[b]); + }); export type HotKeyOptions = { enable?: boolean | (() => boolean); diff --git a/packages/platform/src/tauri/index.ts b/packages/platform/src/tauri/index.ts index 271ed37f..3db70afa 100644 --- a/packages/platform/src/tauri/index.ts +++ b/packages/platform/src/tauri/index.ts @@ -59,6 +59,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = { timeline: true, multiWindow: true, windowChrome: true, + interfaceZoom: true, plugins: true, encryption: true, updater: true, diff --git a/packages/platform/src/types.ts b/packages/platform/src/types.ts index de077348..2969d301 100644 --- a/packages/platform/src/types.ts +++ b/packages/platform/src/types.ts @@ -262,6 +262,11 @@ export interface PlatformCapabilities { * chrome should be reserved or drawn. */ windowChrome: boolean; + /** + * The app zooms its own interface, and so owns Cmd/Ctrl `+`, `-` and `0`. + * False in a browser, where those keys are already the browser's. + */ + interfaceZoom: boolean; /** The plugin runtime. */ plugins: boolean; /** Workspace encryption backed by a key the host keeps. */ diff --git a/packages/platform/src/web/README.md b/packages/platform/src/web/README.md index c80cd97d..5bdae5aa 100644 --- a/packages/platform/src/web/README.md +++ b/packages/platform/src/web/README.md @@ -129,7 +129,10 @@ Reported honestly, so callers gate on the question rather than on the host: | True | False | | --- | --- | -| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` | +| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `interfaceZoom`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` | + +`interfaceZoom: false` leaves Cmd/Ctrl `+`, `-` and `0` to the browser instead +of swallowing them, and drops those three rows from the hotkeys screen. `multiWindow: false` means the host cannot open a *second window* on demand — what `cmd_new_child_window` does for Settings and workspace switching. It is not diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index 530ae4e4..77cac0e1 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -52,6 +52,9 @@ function capabilitiesFor(): PlatformCapabilities { // The browser draws the frame around the page. There are no traffic lights // to leave room for and no window controls to draw. windowChrome: false, + // The browser already zooms the page, on the same keys, and remembers it + // per site. The app stays out of the way. + interfaceZoom: false, plugins: false, encryption: false, updater: false, From 569f552d796103cf5b33eb5c036b81d371f0f4d6 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 17 Aug 2026 12:23:08 -0700 Subject: [PATCH 2/2] Stop sending scope on the authorization code token request (#579) --- plugins/auth-oauth2/src/fetchAccessToken.ts | 8 +- .../tests/fetchAccessToken.test.ts | 93 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 plugins/auth-oauth2/tests/fetchAccessToken.test.ts diff --git a/plugins/auth-oauth2/src/fetchAccessToken.ts b/plugins/auth-oauth2/src/fetchAccessToken.ts index 270c2320..32e43b9c 100644 --- a/plugins/auth-oauth2/src/fetchAccessToken.ts +++ b/plugins/auth-oauth2/src/fetchAccessToken.ts @@ -31,7 +31,13 @@ export async function fetchAccessToken( ], }; - if (scope) httpRequest.body?.form.push({ name: "scope", value: scope }); + // RFC 6749 §4.1.3 doesn't define scope for the authorization code token + // request, so strict servers (OpenIddict) reject it outright. Scope belongs on + // the authorize request, which already sends it. Every other grant does define + // it: §4.3.2 password, §4.4.2 client credentials, §6 refresh. + if (scope && grantType !== "authorization_code") { + httpRequest.body?.form.push({ name: "scope", value: scope }); + } if (audience) httpRequest.body?.form.push({ name: "audience", value: audience }); if ("clientAssertion" in args) { diff --git a/plugins/auth-oauth2/tests/fetchAccessToken.test.ts b/plugins/auth-oauth2/tests/fetchAccessToken.test.ts new file mode 100644 index 00000000..5ee0206d --- /dev/null +++ b/plugins/auth-oauth2/tests/fetchAccessToken.test.ts @@ -0,0 +1,93 @@ +import type { HttpRequest } from "@yaakapp/api"; +import { describe, expect, test } from "vite-plus/test"; +import { fetchAccessToken } from "../src/fetchAccessToken"; + +/** + * Captures the request handed to ctx.httpRequest.send so tests can assert on the + * form body, and replies with a minimal successful token response. + */ +function createMockContext() { + const sent: Partial[] = []; + + const ctx = { + httpRequest: { + async send({ httpRequest }: { httpRequest: Partial }) { + sent.push(httpRequest); + return { + httpResponse: { status: 200, error: null }, + body: { + async text() { + return JSON.stringify({ access_token: "token-123" }); + }, + }, + }; + }, + }, + } as never; + + return { ctx, sent }; +} + +function formNames(httpRequest: Partial) { + return (httpRequest.body?.form ?? []).map((p: { name: string }) => p.name); +} + +function formValue(httpRequest: Partial, name: string) { + return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value; +} + +const baseArgs = { + clientId: "client-123", + accessTokenUrl: "https://auth.example.com/token", + scope: "openid profile", + audience: null, + clientSecret: "secret", + credentialsInBody: true, + params: [], +}; + +describe("fetchAccessToken scope handling", () => { + test("omits scope for the authorization code grant", async () => { + const { ctx, sent } = createMockContext(); + + await fetchAccessToken(ctx, { + ...baseArgs, + grantType: "authorization_code", + params: [{ name: "code", value: "abc" }], + }); + + expect(formNames(sent[0]!)).not.toContain("scope"); + // The rest of the request is untouched + expect(formValue(sent[0]!, "grant_type")).toBe("authorization_code"); + expect(formValue(sent[0]!, "code")).toBe("abc"); + }); + + test("sends scope for the client credentials grant", async () => { + const { ctx, sent } = createMockContext(); + + await fetchAccessToken(ctx, { ...baseArgs, grantType: "client_credentials" }); + + expect(formValue(sent[0]!, "scope")).toBe("openid profile"); + }); + + test("sends scope for the password grant", async () => { + const { ctx, sent } = createMockContext(); + + await fetchAccessToken(ctx, { ...baseArgs, grantType: "password" }); + + expect(formValue(sent[0]!, "scope")).toBe("openid profile"); + }); + + test("still sends audience for the authorization code grant", async () => { + const { ctx, sent } = createMockContext(); + + await fetchAccessToken(ctx, { + ...baseArgs, + grantType: "authorization_code", + audience: "https://api.example.com", + }); + + expect(formValue(sent[0]!, "audience")).toBe("https://api.example.com"); + expect(formNames(sent[0]!)).not.toContain("scope"); + }); +});