mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-27 13:54:11 +02:00
Merge origin/main
This commit is contained in:
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
|||||||
<HotkeyRow
|
<HotkeyRow
|
||||||
key={action}
|
key={action}
|
||||||
action={action}
|
action={action}
|
||||||
currentKeys={hotkeys[action]}
|
currentKeys={hotkeys[action] ?? []}
|
||||||
defaultKeys={defaultHotkeys[action]}
|
defaultKeys={defaultHotkeys[action]}
|
||||||
onSave={async (keys) => {
|
onSave={async (keys) => {
|
||||||
const newHotkeys = { ...settings.hotkeys };
|
const newHotkeys = { ...settings.hotkeys };
|
||||||
|
|||||||
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
|
|||||||
// Merge default hotkeys with custom hotkeys from settings
|
// Merge default hotkeys with custom hotkeys from settings
|
||||||
// Custom hotkeys override defaults for the same action
|
// Custom hotkeys override defaults for the same action
|
||||||
// An empty array means the hotkey is intentionally disabled
|
// An empty array means the hotkey is intentionally disabled
|
||||||
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
||||||
|
for (const action of hotkeyActions) {
|
||||||
|
merged[action] = defaultHotkeys[action];
|
||||||
|
}
|
||||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
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;
|
merged[action as HotkeyAction] = keys;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Helper function to get current hotkeys from the store */
|
/** Helper function to get current hotkeys from the store */
|
||||||
function getHotkeys(): Record<HotkeyAction, string[]> {
|
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
||||||
return jotaiStore.get(hotkeysAtom);
|
return jotaiStore.get(hotkeysAtom);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
|
|||||||
"Space",
|
"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[] = (
|
export const hotkeyActions: HotkeyAction[] = (
|
||||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||||
).sort((a, b) => {
|
)
|
||||||
const scopeA = a.split(".")[0] || "";
|
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
||||||
const scopeB = b.split(".")[0] || "";
|
.sort((a, b) => {
|
||||||
if (scopeA !== scopeB) {
|
const scopeA = a.split(".")[0] || "";
|
||||||
return scopeA.localeCompare(scopeB);
|
const scopeB = b.split(".")[0] || "";
|
||||||
}
|
if (scopeA !== scopeB) {
|
||||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
return scopeA.localeCompare(scopeB);
|
||||||
});
|
}
|
||||||
|
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||||
|
});
|
||||||
|
|
||||||
export type HotKeyOptions = {
|
export type HotKeyOptions = {
|
||||||
enable?: boolean | (() => boolean);
|
enable?: boolean | (() => boolean);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = {
|
|||||||
timeline: true,
|
timeline: true,
|
||||||
multiWindow: true,
|
multiWindow: true,
|
||||||
windowChrome: true,
|
windowChrome: true,
|
||||||
|
interfaceZoom: true,
|
||||||
plugins: true,
|
plugins: true,
|
||||||
encryption: true,
|
encryption: true,
|
||||||
updater: true,
|
updater: true,
|
||||||
|
|||||||
@@ -264,6 +264,11 @@ export interface PlatformCapabilities {
|
|||||||
* chrome should be reserved or drawn.
|
* chrome should be reserved or drawn.
|
||||||
*/
|
*/
|
||||||
windowChrome: boolean;
|
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. */
|
/** The plugin runtime. */
|
||||||
plugins: boolean;
|
plugins: boolean;
|
||||||
/** Workspace encryption backed by a key the host keeps. */
|
/** Workspace encryption backed by a key the host keeps. */
|
||||||
|
|||||||
@@ -138,7 +138,10 @@ Reported honestly, so callers gate on the question rather than on the host:
|
|||||||
|
|
||||||
| True | False |
|
| 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 —
|
`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
|
what `cmd_new_child_window` does for Settings and workspace switching. It is not
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ function capabilitiesFor(): PlatformCapabilities {
|
|||||||
// The browser draws the frame around the page. There are no traffic lights
|
// The browser draws the frame around the page. There are no traffic lights
|
||||||
// to leave room for and no window controls to draw.
|
// to leave room for and no window controls to draw.
|
||||||
windowChrome: false,
|
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,
|
plugins: false,
|
||||||
encryption: false,
|
encryption: false,
|
||||||
updater: false,
|
updater: false,
|
||||||
|
|||||||
@@ -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 (audience) httpRequest.body?.form.push({ name: "audience", value: audience });
|
||||||
|
|
||||||
if ("clientAssertion" in args) {
|
if ("clientAssertion" in args) {
|
||||||
|
|||||||
@@ -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<HttpRequest>[] = [];
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
httpRequest: {
|
||||||
|
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
|
||||||
|
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<HttpRequest>) {
|
||||||
|
return (httpRequest.body?.form ?? []).map((p: { name: string }) => p.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formValue(httpRequest: Partial<HttpRequest>, 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user