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
28 changes: 28 additions & 0 deletions apps/desktop/src/main/login-shell-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveCommandViaLoginShell } from './login-shell-path'

const onPosix = process.platform !== 'win32'

describe('resolveCommandViaLoginShell', () => {
// The core of issue #73: GUI apps inherit a minimal PATH, so a bare command
// name can't be resolved. A login shell sources the user's profile and
// returns an absolute path. `sh` is guaranteed present on POSIX systems.
it.skipIf(!onPosix)('resolves a ubiquitous command to an absolute path', async () => {
const resolved = await resolveCommandViaLoginShell('sh')
expect(resolved).toBeTruthy()
expect(path.isAbsolute(resolved as string)).toBe(true)
expect(path.basename(resolved as string)).toBe('sh')
})

it('returns null for a command that does not exist', async () => {
expect(await resolveCommandViaLoginShell('zen-not-a-real-binary-9f3a2b')).toBeNull()
})

it('rejects unsafe command names without spawning a shell', async () => {
expect(await resolveCommandViaLoginShell('rg; rm -rf /')).toBeNull()
expect(await resolveCommandViaLoginShell('$(touch /tmp/zen-pwned)')).toBeNull()
expect(await resolveCommandViaLoginShell('rg fzf')).toBeNull()
expect(await resolveCommandViaLoginShell('')).toBeNull()
})
})
73 changes: 73 additions & 0 deletions apps/desktop/src/main/login-shell-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { execFile } from 'node:child_process'
import { promises as fsp, constants as fsConstants } from 'node:fs'
import path from 'node:path'
import { promisify } from 'node:util'

const execFileAsync = promisify(execFile)

// A login shell can be slow if the user's profile is heavy; cap it so a stuck
// shell never blocks search. Resolution is memoized, so this runs rarely.
const LOGIN_SHELL_TIMEOUT_MS = 5_000
// Memoize resolved locations so we don't spawn a shell on every capability
// check / search. Short enough to self-heal after a tool is installed mid-run.
const RESOLUTION_TTL_MS = 60_000

// Binary names are interpolated into a shell `command -v` invocation, so only
// ever accept bare tokens — never anything that could break out of the word.
const SAFE_COMMAND = /^[A-Za-z0-9._-]+$/

const cache = new Map<string, { at: number; value: string | null }>()

/**
* Resolve a command to an absolute path using the user's login shell.
*
* GUI apps launched from Finder/Dock on macOS (and similar elsewhere) inherit
* only a minimal PATH (e.g. `/usr/bin:/bin:/usr/sbin:/sbin`), so tools
* installed by Homebrew, cargo, npm, nix, etc. aren't resolvable by their bare
* name. Spawning a *login* shell (`$SHELL -lc 'command -v <cmd>'`) queries the
* PATH the user actually has configured — the same approach the CLI-install and
* Raycast integrations already use to locate their tools.
*
* Returns the absolute path, or `null` when the command can't be resolved —
* including on Windows, where GUI apps already inherit the full PATH and the
* POSIX login-shell trick doesn't apply (callers should fall back to the bare
* command name there).
*/
export async function resolveCommandViaLoginShell(command: string): Promise<string | null> {
if (!SAFE_COMMAND.test(command)) return null
if (process.platform === 'win32') return null

const cached = cache.get(command)
if (cached && Date.now() - cached.at < RESOLUTION_TTL_MS) return cached.value

const value = await queryLoginShell(command)
cache.set(command, { at: Date.now(), value })
return value
}

async function queryLoginShell(command: string): Promise<string | null> {
// Try the user's own shell first, then the standard POSIX shells. On
// macOS/Linux those still pick up the login PATH from the system/user
// profile even when the user's interactive shell is something exotic (fish,
// nu) whose `command -v` syntax differs from POSIX.
const shells = Array.from(
new Set([process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean))
) as string[]

for (const shellPath of shells) {
try {
await fsp.access(shellPath, fsConstants.X_OK)
const { stdout } = await execFileAsync(shellPath, ['-lc', `command -v ${command}`], {
encoding: 'utf8',
timeout: LOGIN_SHELL_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
windowsHide: true
})
const resolved = String(stdout).trim().split(/\r?\n/)[0]?.trim()
if (resolved && path.isAbsolute(resolved)) return resolved
} catch {
/* shell missing here, or command not found in it — try the next one */
}
}
return null
}
12 changes: 11 additions & 1 deletion apps/desktop/src/main/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'node:path'
import { promisify } from 'node:util'
import { app } from 'electron'
import { recordMainPerf } from './perf'
import { resolveCommandViaLoginShell } from './login-shell-path'
import {
DEFAULT_DAILY_NOTES_DIRECTORY,
DEFAULT_WEEKLY_NOTES_DIRECTORY,
Expand Down Expand Up @@ -1300,7 +1301,16 @@ async function searchExecutable(
paths: Required<VaultTextSearchToolPaths>
): Promise<string | null> {
const configured = kind === 'ripgrep' ? paths.ripgrepPath : paths.fzfPath
if (!configured) return kind === 'ripgrep' ? 'rg' : 'fzf'
if (!configured) {
// Auto mode. GUI apps launched from Finder/Dock inherit only a minimal
// PATH (/usr/bin:/bin:/usr/sbin:/sbin), so the bare command name often
// isn't resolvable even when rg/fzf are installed in a non-standard place
// (Homebrew, cargo, nix, …). Resolve through the user's login shell so the
// absolute path flows into both detection and execution; fall back to the
// bare name so an already-correct PATH (and Windows) keeps working. (#73)
const command = kind === 'ripgrep' ? 'rg' : 'fzf'
return (await resolveCommandViaLoginShell(command)) ?? command
}
if (!path.isAbsolute(configured)) return null

const normalized = path.resolve(configured)
Expand Down
25 changes: 24 additions & 1 deletion apps/desktop/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ module.exports = {
DEFAULT: 'rgb(var(--z-accent) / <alpha-value>)',
soft: 'rgb(var(--z-accent-soft) / <alpha-value>)',
muted: 'rgb(var(--z-accent-muted) / <alpha-value>)'
}
},
danger: 'rgb(var(--z-red) / <alpha-value>)',
success: 'rgb(var(--z-green) / <alpha-value>)',
warning: 'rgb(var(--z-yellow) / <alpha-value>)'
},
fontFamily: {
sans: [
Expand All @@ -43,6 +46,26 @@ module.exports = {
panel:
'0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)',
float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)'
},
fontSize: {
'2xs': ['0.6875rem', { lineHeight: '1rem' }]
},
zIndex: {
dropdown: '40',
palette: '50',
modal: '70',
nested: '75',
popover: '80',
toast: '90'
},
maxWidth: {
'dialog-xs': '420px',
'dialog-sm': '440px',
'dialog-md': '560px',
'dialog-lg': '720px',
'dialog-xl': '900px',
'dialog-2xl': '1120px',
'dialog-3xl': '1360px'
}
}
},
Expand Down
25 changes: 24 additions & 1 deletion apps/web/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export default {
DEFAULT: 'rgb(var(--z-accent) / <alpha-value>)',
soft: 'rgb(var(--z-accent-soft) / <alpha-value>)',
muted: 'rgb(var(--z-accent-muted) / <alpha-value>)'
}
},
danger: 'rgb(var(--z-red) / <alpha-value>)',
success: 'rgb(var(--z-green) / <alpha-value>)',
warning: 'rgb(var(--z-yellow) / <alpha-value>)'
},
fontFamily: {
sans: [
Expand All @@ -43,6 +46,26 @@ export default {
panel:
'0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)',
float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)'
},
fontSize: {
'2xs': ['0.6875rem', { lineHeight: '1rem' }]
},
zIndex: {
dropdown: '40',
palette: '50',
modal: '70',
nested: '75',
popover: '80',
toast: '90'
},
maxWidth: {
'dialog-xs': '420px',
'dialog-sm': '440px',
'dialog-md': '560px',
'dialog-lg': '720px',
'dialog-xl': '900px',
'dialog-2xl': '1120px',
'dialog-3xl': '1360px'
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ function AppUpdateNotice({
<span className="h-2 w-2 shrink-0 rounded-full bg-accent shadow-[0_0_0_4px_rgb(var(--z-accent)/0.12)]" />
<span className="min-w-0 truncate font-medium">{label}</span>
{updateState?.phase === 'downloading' && (
<span className="shrink-0 rounded-md bg-paper-200/80 px-1.5 py-0.5 text-[11px] font-medium text-ink-600">
<span className="shrink-0 rounded-md bg-paper-200/80 px-1.5 py-0.5 text-xs font-medium text-ink-600">
{Math.round(updateState.progressPercent ?? 0)}%
</span>
)}
Expand Down
14 changes: 7 additions & 7 deletions packages/app-core/src/components/ArchiveView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ export function ArchiveView(): JSX.Element {

<section
ref={rootRef}
className="overflow-hidden rounded-[24px] border border-paper-300/70 bg-paper-50/34 shadow-[0_12px_42px_rgba(15,23,42,0.06)]"
className="overflow-hidden rounded-3xl border border-paper-300/70 bg-paper-50/34 shadow-[0_12px_42px_rgba(15,23,42,0.06)]"
>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
Expand Down Expand Up @@ -445,12 +445,12 @@ export function ArchiveView(): JSX.Element {
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span className="truncate text-sm font-medium text-ink-900">{note.title}</span>
<span className="text-[11px] uppercase tracking-[0.16em] text-ink-400">
<span className="text-xs uppercase tracking-[0.16em] text-ink-500">
{formatDate(note.updatedAt)}
</span>
</div>
<div className="mt-0.5 truncate text-[11px] text-ink-400">{note.path}</div>
<div className="mt-1 line-clamp-1 text-[13px] leading-5 text-ink-600">
<div className="mt-0.5 truncate text-xs text-ink-500">{note.path}</div>
<div className="mt-1 line-clamp-1 text-sm leading-5 text-ink-600">
{note.excerpt || 'Empty note'}
</div>
</div>
Expand All @@ -461,7 +461,7 @@ export function ArchiveView(): JSX.Element {
e.stopPropagation()
void openNote(note.path)
}}
className="inline-flex items-center gap-1.5 rounded-lg bg-paper-100/85 px-2.5 py-1 text-[11px] font-medium text-ink-700 transition-colors hover:bg-paper-200 hover:text-ink-900"
className="inline-flex items-center gap-1.5 rounded-lg bg-paper-100/85 px-2.5 py-1 text-xs font-medium text-ink-700 transition-colors hover:bg-paper-200 hover:text-ink-900"
>
<ArrowUpRightIcon width={13} height={13} />
Open
Expand All @@ -472,7 +472,7 @@ export function ArchiveView(): JSX.Element {
e.stopPropagation()
void unarchiveNote(note)
}}
className="inline-flex items-center gap-1.5 rounded-lg bg-paper-100/85 px-2.5 py-1 text-[11px] font-medium text-ink-700 transition-colors hover:bg-paper-200 hover:text-ink-900"
className="inline-flex items-center gap-1.5 rounded-lg bg-paper-100/85 px-2.5 py-1 text-xs font-medium text-ink-700 transition-colors hover:bg-paper-200 hover:text-ink-900"
>
<ArrowUpRightIcon width={13} height={13} />
Unarchive
Expand All @@ -483,7 +483,7 @@ export function ArchiveView(): JSX.Element {
e.stopPropagation()
void moveNoteToTrash(note)
}}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-500/10 px-2.5 py-1 text-[11px] font-medium text-[rgb(var(--z-red))] transition-colors hover:bg-red-500/16"
className="inline-flex items-center gap-1.5 rounded-lg bg-red-500/10 px-2.5 py-1 text-xs font-medium text-[rgb(var(--z-red))] transition-colors hover:bg-red-500/16"
>
<TrashIcon width={13} height={13} />
Trash
Expand Down
23 changes: 8 additions & 15 deletions packages/app-core/src/components/BufferPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { isTrashTabPath } from '@shared/trash'
import { isQuickNotesTabPath } from '@shared/quick-notes'
import { resolveSystemFolderLabels, type SystemFolderLabels } from '../lib/system-folder-labels'
import { focusEditorNormalMode } from '../lib/editor-focus'
import { Modal } from './ui/Modal'

interface BufferEntry {
path: string
Expand Down Expand Up @@ -274,15 +275,8 @@ export function BufferPalette(): JSX.Element {
}

return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/45 pt-[15vh] backdrop-blur-sm"
onClick={close}
>
<div
className="w-[min(560px,90vw)] overflow-hidden rounded-xl bg-paper-100 shadow-float ring-1 ring-paper-300/70"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-paper-300/70 px-4 py-3">
<Modal size="md" layer="palette" onClose={close} closeOnEsc={false}>
<div className="border-b border-paper-300/70 px-4 py-3">
<input
ref={inputRef}
value={query}
Expand Down Expand Up @@ -329,24 +323,24 @@ export function BufferPalette(): JSX.Element {
{entry.title}
{entry.dirty && (
<span
className="ml-2 align-middle text-[11px] text-accent"
className="ml-2 align-middle text-xs text-accent"
aria-label="Unsaved changes"
>
</span>
)}
</span>
<span className="shrink-0 truncate text-[11px] text-ink-400">
<span className="shrink-0 truncate text-xs text-ink-400">
{entry.virtual ? 'virtual' : entry.subtitle}
</span>
<span className="shrink-0 text-[11px] uppercase tracking-wide text-ink-400">
<span className="shrink-0 text-xs uppercase tracking-wide text-ink-400">
{entry.badge}
</span>
</button>
))
)}
</div>
<div className="flex items-center justify-end gap-4 border-t border-paper-300/70 bg-paper-100 px-4 py-2 text-[11px] text-ink-500">
<div className="flex items-center justify-end gap-4 border-t border-paper-300/70 bg-paper-100 px-4 py-2 text-xs text-ink-500">
<span>
<kbd className="rounded bg-paper-200 px-1">↑↓</kbd>{' '}
<kbd className="rounded bg-paper-200 px-1">Ctrl+N/P</kbd> move
Expand All @@ -358,7 +352,6 @@ export function BufferPalette(): JSX.Element {
<kbd className="rounded bg-paper-200 px-1">esc</kbd> close
</span>
</div>
</div>
</div>
</Modal>
)
}
Loading
Loading