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 dd1faa94..9a1cf2f9 100644 --- a/packages/platform/src/tauri/index.ts +++ b/packages/platform/src/tauri/index.ts @@ -60,6 +60,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 f253c564..a064f7c9 100644 --- a/packages/platform/src/types.ts +++ b/packages/platform/src/types.ts @@ -264,6 +264,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 667b20fd..fdebbad8 100644 --- a/packages/platform/src/web/README.md +++ b/packages/platform/src/web/README.md @@ -138,7 +138,10 @@ Reported honestly, so callers gate on the question rather than on the host: | True | False | | --- | --- | -| `httpSending`, `timeline`, `cookieJar` | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `multiWindow`, `windowChrome`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` | +| `httpSending`, `timeline`, `cookieJar` | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `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 f664331b..7e97f8a4 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -57,6 +57,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, 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"); + }); +});