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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run test:extractor
- run: npm run build

cli:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"gen:catalog": "node scripts/gen-catalog.mjs",
"prebuild": "npm run gen:catalog",
"prebuild:renderer": "npm run gen:catalog",
"test:extractor": "node scripts/test-extract-base-url.mjs",
"package": "npm run build && electron-builder"
},
"dependencies": {
Expand Down
47 changes: 47 additions & 0 deletions scripts/test-extract-base-url.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
// Smoke tests for the clipboard URL extraction regex in src/renderer/App.tsx.
// We can't run TypeScript natively with the app's webpack config, so we
// re-implement the function here verbatim and assert against the same cases.
'use strict'

/**
* Mirror of `extractBaseUrl` in src/renderer/App.tsx — keep in sync.
*/
function extractBaseUrl(text) {
const trimmed = text.trim()
const m = trimmed.match(/^https?:\/\/[^\s/?#]+/i)
if (!m) return null
return m[0].replace(/\/+$/, '')
}

const cases = [
['https://gateway.acme.com', 'https://gateway.acme.com'],
['http://localhost:4000/', 'http://localhost:4000'],
['https://gateway.ai.cloudflare.com/v1/<account_id>', 'https://gateway.ai.cloudflare.com'],
['\n https://api.example.com/v1 \n', 'https://api.example.com'],
['mailto:nope@example.com', null],
['', null],
['https://', null],
['api.example.com', null],
['https://example.com/with/path', 'https://example.com'],
['https://en.wikipedia.org/wiki/Claude_(language_model)', 'https://en.wikipedia.org'],
]

let failed = 0
for (const [input, expected] of cases) {
const got = extractBaseUrl(input)
if (got === expected) {
console.log(` ✓ ${JSON.stringify(input).slice(0, 60)} → ${JSON.stringify(got)}`)
} else {
failed++
console.error(
` ✗ ${JSON.stringify(input)}:\n expected ${JSON.stringify(expected)}\n got ${JSON.stringify(got)}`,
)
}
}

if (failed > 0) {
console.error(`\n${failed} case(s) failed`)
process.exit(1)
}
console.log('\nall clipboard url extraction cases passed')
11 changes: 11 additions & 0 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,17 @@ export function registerIpcHandlers(): void {
return { ok: false as const, error: errMsg(err) }
}
})

ipcMain.handle(CHANNELS.clipboardRead, () => {
try {
const text = clipboard.readText().trim()
// Truncate to keep IPC payloads bounded — suggestion is short anyway.
const truncated = text.length > 4096 ? text.slice(0, 4096) : text
return { ok: true as const, text: truncated }
} catch (err) {
return { ok: false as const, error: errMsg(err) }
}
})
}

function errMsg(err: unknown): string {
Expand Down
12 changes: 12 additions & 0 deletions src/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export interface HoistAPI {
probe: {
run: (req: ProbeRequest) => Promise<ProbeResponse>
}
clipboard: {
/** Read the current clipboard text. Renderer is the only caller;
* the main process never invokes this opportunistically. */
read: () => Promise<ClipboardReadResponse>
}
}

export interface ClipboardReadResponse {
ok: boolean
error?: string
/** Trimmed text if `ok`. Truncated to 4096 chars to keep IPC payloads bounded. */
text?: string
}

export interface VaultListResponse {
Expand Down
3 changes: 3 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const api: HoistAPI = {
probe: {
run: (req) => ipcRenderer.invoke(CHANNELS.probeRun, req),
},
clipboard: {
read: () => ipcRenderer.invoke(CHANNELS.clipboardRead),
},
}

contextBridge.exposeInMainWorld('hoist', api)
73 changes: 72 additions & 1 deletion src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,30 @@ function GatewayStep({ onBack, onNext }: { onBack: () => void; onNext: () => voi
}[]>([])
const [error, setError] = useState<string | null>(null)
const [effectiveBaseUrl, setEffectiveBaseUrl] = useState<string | null>(null)
const [clipboardSuggestion, setClipboardSuggestion] = useState<string | null>(null)

useEffect(() => {
refreshAll()
void loadClipboardSuggestion()
}, [])

/**
* If the clipboard currently holds a URL that looks like a gateway base URL,
* surface it as a one-click suggestion. We never read the clipboard
* passively in main; this single call is gated behind the user landing
* on the Gateway step.
*/
async function loadClipboardSuggestion() {
try {
const res = await window.hoist.clipboard.read()
if (!res.ok || !res.text) return
const url = extractBaseUrl(res.text)
if (url && url !== baseUrl) setClipboardSuggestion(url)
} catch {
// best-effort; suggestion is optional
}
}

async function refreshAll() {
const [g, p, h, d] = await Promise.all([
window.hoist.gateway.list(),
Expand Down Expand Up @@ -524,8 +543,35 @@ function GatewayStep({ onBack, onNext }: { onBack: () => void; onNext: () => voi
style={{ ...styles.input, width: '100%' }}
value={baseUrl}
placeholder="https://gateway.example.com"
onChange={(e) => setBaseUrl(e.target.value)}
onChange={(e) => {
setBaseUrl(e.target.value)
// Discard suggestion once the user edits anything.
if (clipboardSuggestion) setClipboardSuggestion(null)
}}
/>
{clipboardSuggestion && (
<div style={styles.clipboardSuggestion}>
<span style={styles.envHint}>
Clipboard has <code style={styles.code}>{clipboardSuggestion}</code>
</span>
<button
style={styles.miniBtn}
onClick={() => {
setBaseUrl(clipboardSuggestion)
setClipboardSuggestion(null)
}}
>
Use this URL
</button>
<button
style={styles.miniBtn}
onClick={() => setClipboardSuggestion(null)}
title="Dismiss"
>
</button>
</div>
)}
{selectedGateway?.selfHostedHint && (
<div style={styles.envHint}>{selectedGateway.selfHostedHint}</div>
)}
Expand Down Expand Up @@ -643,6 +689,20 @@ function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}

/**
* Pull the first https? URL out of arbitrary clipboard text. Returns null
* if nothing on the clipboard looks like a base URL we can route to.
*
* Restriction: no whitespace or newlines inside the URL — the clipboard
* may carry arbitrary text and we don't want partial paths leaking in.
*/
export function extractBaseUrl(text: string): string | null {
const trimmed = text.trim()
const m = trimmed.match(/^https?:\/\/[^\s/?#]+/i)
if (!m) return null
return m[0].replace(/\/+$/, '')
}

const styles: Record<string, React.CSSProperties> = {
shell: {
display: 'flex',
Expand Down Expand Up @@ -1010,4 +1070,15 @@ const styles: Record<string, React.CSSProperties> = {
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
overflow: 'auto',
},
clipboardSuggestion: {
marginTop: 8,
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'var(--accent-glow)',
border: '1px solid rgba(124, 92, 252, 0.25)',
borderRadius: 8,
padding: '8px 10px',
flexWrap: 'wrap' as const,
},
}
1 change: 1 addition & 0 deletions src/shared/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const CHANNELS = {
gatewayList: 'gateway:list',
gatewayApply: 'gateway:apply',
harnessConfigShow: 'harness:configShow',
clipboardRead: 'clipboard:read',
} as const

export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]
Loading