Skip to content

Commit a33bc59

Browse files
alicodingclaude
andauthored
fix: clipboard copy works over remote HTTP -- secure-context fallback via copy-to-clipboard (0108 friction #7) (#238)
navigator.clipboard exists only in secure contexts, so every copy action (Copy for AI, Copy as context, cloud link, validation/test copies) silently did nothing on a Mill server reached over plain http from another device. One shared door (shared/clipboardWrite.ts) tries the async API, falls back to the execCommand path via the vetted copy-to-clipboard package, and throws when both fail so existing onError feedback fires. All 8 write sites converted; unit tests pin the fallback and both-doors-fail paths; verified live against a server bound to the Tailscale interface (isSecureContext false, fallback fired, copy succeeded). Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 891e9f4 commit a33bc59

9 files changed

Lines changed: 89 additions & 8 deletions

File tree

frontend/package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@primer/react": "^38.35.0",
2121
"@wailsio/runtime": "3.0.0-beta.5",
2222
"@xyflow/react": "^12.11.2",
23+
"copy-to-clipboard": "^4.0.2",
2324
"cronstrue": "^3.24.0",
2425
"elkjs": "^0.12.0",
2526
"genson-js": "^0.0.8",

frontend/src/app/QuickPanelReplyReview.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Banner, Button, Checkbox, FormControl, Stack, Text } from '@primer/reac
44
import { ArrowLeftIcon, CopyIcon } from '@primer/octicons-react'
55
import { AtlasService, ExecutionService, RunKind } from '../shared/bindings'
66
import type { ClipbridgeReplyPreview } from '../shared/bindings'
7+
import { writeClipboardText } from '../shared/clipboardWrite'
78
import styles from './QuickPanelClipboardApply.module.css'
89

910
// The clipboard bridge's review surface (goal 0099) -- the fourth
@@ -39,7 +40,7 @@ export function QuickPanelReplyReview({ preview, onCancel, onApplied }: Props) {
3940

4041
const copyCorrection = (problems: string[], declined: string[]) => {
4142
AtlasService.CorrectionEnvelope(problems, declined)
42-
.then((envelope) => navigator.clipboard.writeText(envelope))
43+
.then((envelope) => writeClipboardText(envelope))
4344
.then(() => setCopied(true))
4445
.catch((err) => setConfirmError(String(err)))
4546
}

frontend/src/atlas/atlasCardShare.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Card } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
22
import { AtlasService } from '../shared/bindings'
3+
import { writeClipboardText } from '../shared/clipboardWrite'
34

45
// The card-level share actions (goal 0063, ADR-0038): copy-as-context
56
// (with an explicit with/without-attachments toggle), copy-cloud-link,
@@ -14,7 +15,7 @@ export function atlasCardShareActions(card: Card, onError: (message: string) =>
1415
const copyAsContext = async (withAttachments: boolean): Promise<void> => {
1516
try {
1617
const block = await AtlasService.CardContextBlock(card.ID, withAttachments)
17-
await navigator.clipboard.writeText(block)
18+
await writeClipboardText(block)
1819
} catch (err) {
1920
onError(String(err))
2021
}
@@ -23,15 +24,15 @@ export function atlasCardShareActions(card: Card, onError: (message: string) =>
2324
const copyForAI = async (): Promise<void> => {
2425
try {
2526
const envelope = await AtlasService.CardContextEnvelope(card.ID)
26-
await navigator.clipboard.writeText(envelope)
27+
await writeClipboardText(envelope)
2728
} catch (err) {
2829
onError(String(err))
2930
}
3031
}
3132

3233
const copyCloudLink = async (): Promise<void> => {
3334
try {
34-
await navigator.clipboard.writeText(card.Source)
35+
await writeClipboardText(card.Source)
3536
} catch (err) {
3637
onError(String(err))
3738
}

frontend/src/atlas/atlasSpaceShareActions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AtlasService } from '../shared/bindings'
2+
import { writeClipboardText } from '../shared/clipboardWrite'
23

34
// The space-level share actions (goal 0063, ADR-0038), the space
45
// counterpart to atlasCardShare.ts's own card-level twin -- both
@@ -9,7 +10,7 @@ export function atlasSpaceShareActions(spaceID: string, onError: (message: strin
910
const bundleContext = async (withAttachments: boolean): Promise<void> => {
1011
try {
1112
const text = await AtlasService.SpaceBundleContext(spaceID, withAttachments)
12-
await navigator.clipboard.writeText(text)
13+
await writeClipboardText(text)
1314
} catch (err) {
1415
onError(String(err))
1516
}
@@ -18,7 +19,7 @@ export function atlasSpaceShareActions(spaceID: string, onError: (message: strin
1819
const copyLinks = async (): Promise<void> => {
1920
try {
2021
const text = await AtlasService.SpaceLinksList(spaceID)
21-
await navigator.clipboard.writeText(text)
22+
await writeClipboardText(text)
2223
} catch (err) {
2324
onError(String(err))
2425
}

frontend/src/composition/ValidationPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AlertFillIcon, CheckIcon, CopyIcon, XCircleFillIcon } from '@primer/oct
55
import type { Issue } from '../../bindings/github.com/alicoding/mill/internal/domain/composition/models'
66
import { formatIssuesForCopy } from './validationCopy'
77
import styles from './CompositionCanvas.module.css'
8+
import { writeClipboardText } from '../shared/clipboardWrite'
89

910
// The editor's own authoring-validation surface (docs/adr/0028): a
1011
// toolbar badge naming how many errors/warnings the current draft
@@ -34,7 +35,7 @@ export function ValidationSurface({ issues, workflowLabel, workflowId, onSelectI
3435
// without hand-transcribing panel rows. Same navigator.clipboard
3536
// precedent as RequestTestPanel's Copy-error button.
3637
const copyIssues = () => {
37-
void navigator.clipboard.writeText(formatIssuesForCopy(t, workflowLabel, workflowId, issues)).then(() => {
38+
void writeClipboardText(formatIssuesForCopy(t, workflowLabel, workflowId, issues)).then(() => {
3839
setCopied(true)
3940
setTimeout(() => setCopied(false), 1500)
4041
})

frontend/src/configure/RequestTestPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Button, FormControl, IconButton, Label, Select, SegmentedControl, Stack
44
import { StatusStamp } from '../shared/StatusStamp'
55
import { CopyIcon, PlayIcon, SyncIcon } from '@primer/octicons-react'
66
import { ConfigureService } from '../shared/bindings'
7+
import { writeClipboardText } from '../shared/clipboardWrite'
78
import type { AuthConfig, AuthType, JOSEConfig } from '../../bindings/github.com/alicoding/mill/internal/domain/httprequest/models'
89
import type { TestHTTPRequestResult } from '../shared/bindings'
910
import type { ManualOperation } from './openapiSynth'
@@ -249,7 +250,7 @@ export function RequestTestPanel({
249250
aria-label={t('requestTestPanel.copyAriaLabel')}
250251
size="small"
251252
variant="invisible"
252-
onClick={() => { void navigator.clipboard.writeText(entry.Error || entry.Body) }}
253+
onClick={() => { void writeClipboardText(entry.Error || entry.Body) }}
253254
data-testid="copy-log-entry"
254255
/>
255256
</Stack>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
3+
const copyMock = vi.hoisted(() => vi.fn())
4+
vi.mock('copy-to-clipboard', () => ({ default: copyMock }))
5+
6+
import { writeClipboardText } from './clipboardWrite'
7+
8+
// Regression: over plain http on another device (the remote server
9+
// instance) navigator.clipboard does not exist, and the old direct
10+
// writeText calls silently dropped every copy action.
11+
describe('writeClipboardText', () => {
12+
afterEach(() => {
13+
vi.unstubAllGlobals()
14+
copyMock.mockReset()
15+
})
16+
17+
it('uses navigator.clipboard when available', async () => {
18+
const writeText = vi.fn().mockResolvedValue(undefined)
19+
vi.stubGlobal('navigator', { clipboard: { writeText } })
20+
await writeClipboardText('hello')
21+
expect(writeText).toHaveBeenCalledWith('hello')
22+
expect(copyMock).not.toHaveBeenCalled()
23+
})
24+
25+
it('falls back to the execCommand path when navigator.clipboard is absent', async () => {
26+
vi.stubGlobal('navigator', {})
27+
copyMock.mockReturnValue(true)
28+
await writeClipboardText('hello')
29+
expect(copyMock).toHaveBeenCalledWith('hello')
30+
})
31+
32+
it('falls back when the clipboard API rejects (permission denied)', async () => {
33+
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
34+
vi.stubGlobal('navigator', { clipboard: { writeText } })
35+
copyMock.mockReturnValue(true)
36+
await writeClipboardText('hello')
37+
expect(copyMock).toHaveBeenCalledWith('hello')
38+
})
39+
40+
it('throws when both doors fail, so callers surface real feedback', async () => {
41+
vi.stubGlobal('navigator', {})
42+
copyMock.mockReturnValue(false)
43+
await expect(writeClipboardText('hello')).rejects.toThrow(/Copy failed/)
44+
})
45+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import copy from 'copy-to-clipboard'
2+
3+
// The one clipboard-write door for every copy action. navigator.clipboard
4+
// exists only in secure contexts (https or localhost) -- a Mill server
5+
// reached over plain http on another device (the Tailscale instance) has
6+
// no navigator.clipboard at all, and the old direct writeText calls
7+
// silently did nothing there. Fallback is the execCommand path via the
8+
// vetted copy-to-clipboard package rather than a hand-rolled textarea
9+
// dance. Throws when both doors fail so a caller's existing error
10+
// feedback fires instead of reporting a copy that never happened.
11+
export async function writeClipboardText(text: string): Promise<void> {
12+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
13+
try {
14+
await navigator.clipboard.writeText(text)
15+
return
16+
} catch {
17+
// Permission denied or transiently unavailable -- fall through.
18+
}
19+
}
20+
if (!copy(text)) {
21+
throw new Error('Copy failed: the browser blocked clipboard access')
22+
}
23+
}

0 commit comments

Comments
 (0)