Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
306 changes: 305 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2360,6 +2360,15 @@ Ctrl+Shift+C/V, right-click Edit menu Copy/Paste, and macOS ⌘C/⌘V stay on
their existing paths. App-level shortcuts still do not reclaim Ctrl+C/Ctrl+V
inside `.work-terminal-host`.

**DAN-74 clipboard permission identity shipped (2026-09-21):** Programmatic
clipboard read/write goes through `tauri-plugin-clipboard-manager`
(`writeClipboardText` / `readClipboardText` in `ui/src/lib/clipboard.ts`) so
macOS TCC names Strand instead of the webview origin (`tauri.dev local` /
localhost). The web demo still uses `navigator.clipboard`. Edit-menu
Predefined Copy/Paste and DAN-71 Ctrl+C/V routing are unchanged. Least-privilege
`clipboard-manager:allow-read-text` / `allow-write-text` are on the main
window allowlist.

**Terminal repository overrides and Windows PowerShell follow-up (2026-07-20):**
Settings → Terminal now pairs a repository selector with a shell selector so
every open repository family remains configurable without a long override
Expand Down
4 changes: 4 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,10 @@ community plugins, performance and platform certification from Git feature gaps.
shell-control ownership, exited transcript, and Relaunch (`Work.tsx`).
Windows/Linux Ctrl+C copies the xterm selection else SIGINT; Ctrl+V pastes
(`attachCustomKeyEventHandler`, `terminalClipboardAction`; DAN-71).
Programmatic clipboard read/write uses `tauri-plugin-clipboard-manager` so OS
prompts name Strand (`writeClipboardText` / `readClipboardText` /
`copyToClipboard` in `ui/src/lib/clipboard.ts`; DAN-74). Edit-menu Predefined
Copy/Paste is unchanged.
Fixed-width tabs wheel-scroll, auto-reveal, expose an overflow selector, and
render `TreeFileIcon` symbols. Renderers survive view/repository/workspace
switches. Configurable terminal font/10–32px sizing with a live Settings
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ zeroize = "1"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-dialog = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-shell = "2"
tauri-plugin-os = "2"
tauri-plugin-notification = "2"
Expand Down
2 changes: 2 additions & 0 deletions crates/strand-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"core:webview:allow-set-webview-zoom",
"dialog:allow-open",
"dialog:allow-save",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ fn main() {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_updater::Builder::default().build())
.plugin(tauri_plugin_process::init())
.plugin(
Expand Down
15 changes: 15 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Learnings

## Programmatic clipboard is native so the OS names Strand (2026-09-21)

`navigator.clipboard` in the Tauri webview is attributed to the web origin
(`tauri.dev local` / localhost), not `productName`. All programmatic text
read/write must go through `ui/src/lib/clipboard.ts`, which uses
`tauri-plugin-clipboard-manager` inside the desktop shell and falls back to
`navigator.clipboard` in the web demo. Do not call the browser Clipboard API
from UI call sites. Native Edit-menu Predefined Copy/Paste stays on AppKit /
Win32. Grant exact `clipboard-manager:allow-read-text` and
`allow-write-text` — the plugin default is empty — and keep them in
`scripts/check-release-security.mjs`. The native review harness cannot stub
Tauri `invoke` (those properties are frozen); `writeClipboardText` honors
`window.__strandCaptureClipboardWrite` so Copy feedback can be captured
without touching the OS clipboard. Do not remove that seam.

## SSH reads must stay isolated and bounded (2026-09-06)

Remote identities never enter local filesystem commands. The first SSH surface
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions scripts/check-release-security.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const expectedPermissions = [
'core:webview:allow-set-webview-zoom',
'dialog:allow-open',
'dialog:allow-save',
'clipboard-manager:allow-read-text',
'clipboard-manager:allow-write-text',
'notification:allow-is-permission-granted',
'notification:allow-request-permission',
'notification:allow-notify',
Expand Down
15 changes: 13 additions & 2 deletions scripts/test-review-native.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,19 @@ async function launch() {
await cdp.send('Page.reload');
}
await waitFor('React stores and workspace persistence', () => evaluate('return !!repo && workspaces.getState().loaded && document.querySelectorAll("#root > *").length > 0;'));
// Inspect real feedback rendering without writing the user's system clipboard.
await evaluate('Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text) => { window.__reviewFeedback = text; } } });');
// Capture Copy feedback without writing the OS clipboard. Desktop writes go
// through writeClipboardText → the clipboard plugin, which never hits
// navigator.clipboard. The native-review gate installs
// window.__strandCaptureClipboardWrite; keep navigator as a web fallback.
await evaluate(`
window.__strandCaptureClipboardWrite = async (text) => {
window.__reviewFeedback = text;
};
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: async (text) => { window.__reviewFeedback = text; } },
});
`);
}
async function initRepo(path, committed = true) {
await mkdir(path, { recursive: true });
Expand Down
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@pierre/theme": "1.1.0",
"@pierre/trees": "1.0.0-beta.5",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-os": "^2",
Expand Down
7 changes: 6 additions & 1 deletion ui/src/components/PierreTree.searchAction.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
writeText: vi.fn(),
readText: vi.fn(),
}));

import { SEARCH_ACTION_CSS, SEARCH_ACTION_SPACE } from './PierreTree';

Expand Down
9 changes: 2 additions & 7 deletions ui/src/components/PierreTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { TREE_ICONS } from '../lib/treeIcons';
import { expandTreeSelection, resolveTreeActionTargets } from '../lib/treeSelection';
import type { DiffStatus } from '../lib/types';

export { copyToClipboard } from '../lib/clipboard';

// ─── status mapping ───────────────────────────────────────────────────────
export function diffStatusToGit(s: DiffStatus): GitStatus {
switch (s) {
Expand All @@ -32,13 +34,6 @@ export function diffStatusToGit(s: DiffStatus): GitStatus {
}
}

/** Write `text` to the clipboard, swallowing the rejection clipboard APIs throw
* when the webview denies access (so a copy never surfaces an unhandled
* rejection). */
export function copyToClipboard(text: string): void {
void navigator.clipboard?.writeText(text)?.catch((e) => console.warn('clipboard write failed', e));
}

// ─── public types ─────────────────────────────────────────────────────────

/** Right-click menu item — the same shape the app's ContextMenu consumes. */
Expand Down
149 changes: 149 additions & 0 deletions ui/src/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
writeText: vi.fn(),
readText: vi.fn(),
}));

vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
writeText: mocks.writeText,
readText: mocks.readText,
}));

import { copyToClipboard, readClipboardText, writeClipboardText } from './clipboard';

const webWrite = vi.fn();
const webRead = vi.fn();

function host(): typeof globalThis & { __TAURI_INTERNALS__?: unknown; window?: { __TAURI_INTERNALS__?: unknown } } {
return globalThis as typeof globalThis & { __TAURI_INTERNALS__?: unknown; window?: { __TAURI_INTERNALS__?: unknown } };
}

function stubShell(desktop: boolean, clipboard: { writeText: typeof webWrite; readText: typeof webRead } | undefined = {
writeText: webWrite,
readText: webRead,
}) {
const g = host();
if (desktop) {
g.__TAURI_INTERNALS__ = {};
if (g.window) g.window.__TAURI_INTERNALS__ = {};
} else {
delete g.__TAURI_INTERNALS__;
if (g.window) delete g.window.__TAURI_INTERNALS__;
}
vi.stubGlobal('window', desktop ? { __TAURI_INTERNALS__: {} } : {});
vi.stubGlobal('navigator', clipboard ? { clipboard } : {});
}

describe('clipboard helpers', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

beforeEach(() => {
mocks.writeText.mockReset();
mocks.readText.mockReset();
webWrite.mockReset();
webRead.mockReset();
warn.mockClear();
stubShell(false);
});

afterEach(() => {
delete window.__strandCaptureClipboardWrite;
stubShell(false);
vi.unstubAllGlobals();
});

it('copyToClipboard writes through the native plugin in the desktop shell', async () => {
stubShell(true);
mocks.writeText.mockResolvedValue(undefined);

copyToClipboard('sha');
await vi.waitFor(() => expect(mocks.writeText).toHaveBeenCalledWith('sha'));
expect(webWrite).not.toHaveBeenCalled();
});

it('short-circuits writes through the native-review capture seam', async () => {
stubShell(true);
const capture = vi.fn().mockResolvedValue(undefined);
window.__strandCaptureClipboardWrite = capture;

await writeClipboardText('feedback markdown');
copyToClipboard('copied notes');
await vi.waitFor(() => expect(capture).toHaveBeenCalledWith('copied notes'));

expect(capture).toHaveBeenCalledWith('feedback markdown');
expect(mocks.writeText).not.toHaveBeenCalled();
expect(webWrite).not.toHaveBeenCalled();
});

it('writes through the native plugin in the desktop shell', async () => {
stubShell(true);
mocks.writeText.mockResolvedValue(undefined);

await writeClipboardText('sha');

expect(mocks.writeText).toHaveBeenCalledWith('sha');
expect(webWrite).not.toHaveBeenCalled();
});

it('reads through the native plugin in the desktop shell', async () => {
stubShell(true);
mocks.readText.mockResolvedValue('pasted');

await expect(readClipboardText()).resolves.toBe('pasted');
expect(mocks.readText).toHaveBeenCalled();
expect(webRead).not.toHaveBeenCalled();
});

it('falls back to navigator.clipboard outside Tauri', async () => {
webWrite.mockResolvedValue(undefined);
webRead.mockResolvedValue('demo');

await writeClipboardText('branch');
await expect(readClipboardText()).resolves.toBe('demo');

expect(webWrite).toHaveBeenCalledWith('branch');
expect(webRead).toHaveBeenCalled();
expect(mocks.writeText).not.toHaveBeenCalled();
expect(mocks.readText).not.toHaveBeenCalled();
});

it('copyToClipboard swallows write denials', async () => {
webWrite.mockRejectedValue(new Error('denied'));

copyToClipboard('secret');
await vi.waitFor(() => expect(warn).toHaveBeenCalled());
});

it('readClipboardText returns empty on denial instead of throwing', async () => {
webRead.mockRejectedValue(new Error('denied'));

await expect(readClipboardText()).resolves.toBe('');
expect(warn).toHaveBeenCalled();
});

it('readClipboardText swallows desktop read denials', async () => {
stubShell(true);
mocks.readText.mockRejectedValue(new Error('denied'));

await expect(readClipboardText()).resolves.toBe('');
expect(warn).toHaveBeenCalled();
expect(webRead).not.toHaveBeenCalled();
});

it('readClipboardText returns empty when the web clipboard API is missing', async () => {
stubShell(false, undefined);
Object.defineProperty(globalThis.navigator, 'clipboard', { configurable: true, value: {} });

await expect(readClipboardText()).resolves.toBe('');
expect(warn).not.toHaveBeenCalled();
expect(mocks.readText).not.toHaveBeenCalled();
});

it('propagates desktop write denials from writeClipboardText', async () => {
stubShell(true);
mocks.writeText.mockRejectedValue(new Error('denied'));

await expect(writeClipboardText('x')).rejects.toThrow('denied');
});
});
53 changes: 53 additions & 0 deletions ui/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager';

type ClipboardCapture = (text: string) => void | Promise<void>;

declare global {
interface Window {
/** Native-review / test harness only: short-circuit clipboard writes. */
__strandCaptureClipboardWrite?: ClipboardCapture;
}
}

/** True inside the Tauri webview. Duplicates `isTauri` so this helper does not
* load the IPC command map on every tree/diff import. */
function isDesktopShell(): boolean {
return typeof window !== 'undefined' && Boolean(
(window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__,
);
}

/** Write `text` to the system clipboard. Rejects when the OS or webview
* denies access — callers that must not surface a rejection should use
* {@link copyToClipboard}. */
export async function writeClipboardText(text: string): Promise<void> {
const capture = typeof window !== 'undefined'
? window.__strandCaptureClipboardWrite
: undefined;
if (capture) {
await capture(text);
return;
}
if (isDesktopShell()) {
await writeText(text);
return;
}
await navigator.clipboard.writeText(text);
}

/** Write `text` to the clipboard, swallowing the rejection clipboard APIs throw
* when access is denied (so a copy never surfaces an unhandled rejection). */
export function copyToClipboard(text: string): void {
void writeClipboardText(text).catch((e) => console.warn('clipboard write failed', e));
}

export async function readClipboardText(): Promise<string> {
try {
if (isDesktopShell()) return await readText();
if (!navigator.clipboard?.readText) return '';
return await navigator.clipboard.readText();
} catch (error) {
console.warn('clipboard read failed', error);
return '';
}
}
Loading
Loading