From 65d0277ec7edf82d6867a835f7f0e9a3d4aafa1d Mon Sep 17 00:00:00 2001 From: Corentin <14354161+grallc@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:02:33 +0700 Subject: [PATCH 001/359] fix(chat): disable spellcheck on backend model picker search Browser autocorrect was suggesting "got" for "gpt", forcing an extra keypress to dismiss. --- src/components/chat/toolbar/BackendModelPickerContent.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/chat/toolbar/BackendModelPickerContent.tsx b/src/components/chat/toolbar/BackendModelPickerContent.tsx index 898bb4bd2..22c3a04d3 100644 --- a/src/components/chat/toolbar/BackendModelPickerContent.tsx +++ b/src/components/chat/toolbar/BackendModelPickerContent.tsx @@ -165,6 +165,7 @@ export function BackendModelPickerContent({ setSearch(event.target.value)} onKeyDown={event => { if (event.key === 'Escape') { From 500e6c91f4c54a434fd7c0db18c66833f472f083 Mon Sep 17 00:00:00 2001 From: Pavel 'Strajk' Dolecek Date: Fri, 1 May 2026 18:36:10 +0200 Subject: [PATCH 002/359] feat(ui): consolidate CLI update toasts into titlebar badge+popover --- src/components/titlebar/TitleBar.tsx | 104 +++++++++++++++++++++++++++ src/hooks/useCliVersionCheck.ts | 95 ++++++------------------ src/store/ui-store.ts | 28 ++++++++ 3 files changed, 155 insertions(+), 72 deletions(-) diff --git a/src/components/titlebar/TitleBar.tsx b/src/components/titlebar/TitleBar.tsx index b59976a58..c6b63d494 100644 --- a/src/components/titlebar/TitleBar.tsx +++ b/src/components/titlebar/TitleBar.tsx @@ -12,13 +12,26 @@ import { useUIStore } from '@/store/ui-store' import { useCommandContext } from '@/lib/commands' import { ArrowUpCircle, + Download, Github, Heart, PanelLeft, PanelLeftClose, Settings, + X, } from 'lucide-react' import { usePreferences } from '@/services/preferences' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { + CLI_DISPLAY_NAMES, + resolveCliPathUpdateAction, +} from '@/lib/cli-update' +import type { PendingCliUpdate } from '@/store/ui-store' +import { toast } from 'sonner' import { formatShortcutDisplay, DEFAULT_KEYBINDINGS } from '@/types/keybindings' import { isNativeApp } from '@/lib/environment' import { UnreadBell } from '@/components/unread/UnreadBell' @@ -186,6 +199,7 @@ export function TitleBar({ GitHub )} + {appVersion && } {appVersion && ( + + + + {updates.length} CLI update{updates.length > 1 ? 's' : ''} available + + + +
+ {updates.map(update => ( +
+
+

+ {CLI_DISPLAY_NAMES[update.type]} +

+

+ v{update.currentVersion} → v{update.latestVersion} +

+
+
+ + +
+
+ ))} +
+
+ + ) +} + function UpdateIndicator() { const pendingVersion = useUIStore(state => state.pendingUpdateVersion) if (!pendingVersion) return null diff --git a/src/hooks/useCliVersionCheck.ts b/src/hooks/useCliVersionCheck.ts index 0b75835c3..3b8a4c159 100644 --- a/src/hooks/useCliVersionCheck.ts +++ b/src/hooks/useCliVersionCheck.ts @@ -6,7 +6,6 @@ */ import { useEffect, useRef, useState } from 'react' -import { toast } from 'sonner' import { useClaudeCliStatus, useAvailableCliVersions, @@ -32,11 +31,7 @@ import { isNewerVersion } from '@/lib/version-utils' import { logger } from '@/lib/logger' import { isNativeApp } from '@/lib/environment' import { usePreferences } from '@/services/preferences' -import { - CLI_DISPLAY_NAMES, - resolveCliPathUpdateAction, - type CliType, -} from '@/lib/cli-update' +import type { CliType } from '@/lib/cli-update' interface CliUpdateInfo { type: CliType @@ -206,17 +201,29 @@ export function useCliVersionCheck() { }) } - if (updates.length > 0) { - logger.info('CLI updates available', { updates }) + // Sync store: remove CLIs no longer outdated (e.g. user updated manually), + // merge in newly detected updates. + const currentlyOutdated = new Set( + checks + .filter(c => { + if (!c.info.version || !c.versions?.length) return false + const latestStable = c.versions.find(v => !v.prerelease) + return latestStable && isNewerVersion(latestStable.version, c.info.version) + }) + .map(c => c.type) + ) - if (isInitialCheckRef.current) { - // Delay initial notification to let the app settle - setTimeout(() => { - showUpdateToasts(updates) - }, 5000) - } else { - showUpdateToasts(updates) - } + const { setAvailableCliUpdates, availableCliUpdates } = useUIStore.getState() + const nextUpdates = availableCliUpdates.filter(u => currentlyOutdated.has(u.type)) + for (const u of updates) { + const idx = nextUpdates.findIndex(m => m.type === u.type) + if (idx >= 0) nextUpdates[idx] = u + else nextUpdates.push(u) + } + + if (nextUpdates.length !== availableCliUpdates.length || updates.length > 0) { + if (updates.length > 0) logger.info('CLI updates available', { updates }) + setAvailableCliUpdates(nextUpdates) } isInitialCheckRef.current = false @@ -249,59 +256,3 @@ export function useCliVersionCheck() { ]) } -/** - * Show toast notifications for each CLI update. - * Each CLI gets its own toast with Update and Cancel buttons. - * Toast stays visible until user dismisses it. - */ -function showUpdateToasts(updates: CliUpdateInfo[]) { - const { openCliUpdateModal, openCliLoginModal } = useUIStore.getState() - - for (const update of updates) { - const cliName = CLI_DISPLAY_NAMES[update.type] - const toastId = `cli-update-${update.type}` - - const isPathMode = update.cliSource === 'path' - - toast.info(`${cliName} update available`, { - id: toastId, - description: `v${update.currentVersion} → v${update.latestVersion}`, - duration: Infinity, // Don't auto-dismiss - action: { - label: 'Update', - onClick: () => { - if (isPathMode) { - const action = resolveCliPathUpdateAction( - update.type, - update.cliPath, - update.packageManager, - update.latestVersion - ) - if (action) { - logger.debug( - `[CliVersionCheck] PATH-mode update: type=${update.type} cmd=${action[0]} args=${action[1].join(' ')}` - ) - openCliLoginModal(update.type, action[0], action[1], 'update') - } else { - logger.warn( - `[CliVersionCheck] PATH-mode update with unknown package manager: type=${update.type} pm=${update.packageManager}` - ) - toast.error( - `Can't auto-update ${cliName}. Update it manually via your package manager.` - ) - } - } else { - openCliUpdateModal(update.type) - } - toast.dismiss(toastId) - }, - }, - cancel: { - label: 'Cancel', - onClick: () => { - toast.dismiss(toastId) - }, - }, - }) - } -} diff --git a/src/store/ui-store.ts b/src/store/ui-store.ts index 5e2cdc82e..ffabb2094 100644 --- a/src/store/ui-store.ts +++ b/src/store/ui-store.ts @@ -18,6 +18,15 @@ export type OnboardingStartStep = 'claude' | 'gh' | null export type CliUpdateModalType = 'claude' | 'gh' | 'codex' | 'opencode' | null +export interface PendingCliUpdate { + type: 'claude' | 'gh' | 'codex' | 'opencode' + currentVersion: string + latestVersion: string + cliSource?: 'jean' | 'path' + cliPath?: string | null + packageManager?: string | null +} + export type CliLoginModalType = | 'claude' | 'gh' @@ -98,6 +107,8 @@ interface UIState { pendingUpdateVersion: string | null /** When non-null, shows the update available modal */ updateModalVersion: string | null + /** CLI updates detected — shown as badge+popover in title bar */ + availableCliUpdates: PendingCliUpdate[] toggleLeftSidebar: () => void setLeftSidebarVisible: (visible: boolean) => void setLeftSidebarSize: (size: number) => void @@ -173,6 +184,8 @@ interface UIState { setUIStateInitialized: (initialized: boolean) => void setPendingUpdateVersion: (version: string | null) => void setUpdateModalVersion: (version: string | null) => void + setAvailableCliUpdates: (updates: PendingCliUpdate[]) => void + dismissCliUpdateNotice: (type: PendingCliUpdate['type']) => void chatSearchOpen: boolean setChatSearchOpen: (open: boolean) => void githubDashboardOpen: boolean @@ -241,6 +254,7 @@ export const useUIStore = create()( uiStateInitialized: false, pendingUpdateVersion: null, updateModalVersion: null, + availableCliUpdates: [], chatSearchOpen: false, githubDashboardOpen: false, toggleLeftSidebar: () => @@ -726,6 +740,20 @@ export const useUIStore = create()( 'setUpdateModalVersion' ), + setAvailableCliUpdates: (updates: PendingCliUpdate[]) => + set({ availableCliUpdates: updates }, undefined, 'setAvailableCliUpdates'), + + dismissCliUpdateNotice: (type: PendingCliUpdate['type']) => + set( + state => ({ + availableCliUpdates: state.availableCliUpdates.filter( + u => u.type !== type + ), + }), + undefined, + 'dismissCliUpdateNotice' + ), + setChatSearchOpen: (open: boolean) => set( state => { From 304b5efcaf579d408b56b5d269af9c7e8459e0af Mon Sep 17 00:00:00 2001 From: Pavel 'Strajk' Dolecek Date: Fri, 1 May 2026 18:49:06 +0200 Subject: [PATCH 003/359] fix: address review feedback - Move triggerCliUpdate into CliUpdatesIndicator component as a callback - Build currentlyOutdated Set during first pass instead of recalculating --- src/components/titlebar/TitleBar.tsx | 58 ++++++++++++++-------------- src/hooks/useCliVersionCheck.ts | 11 +----- 2 files changed, 30 insertions(+), 39 deletions(-) diff --git a/src/components/titlebar/TitleBar.tsx b/src/components/titlebar/TitleBar.tsx index c6b63d494..6f4d174e9 100644 --- a/src/components/titlebar/TitleBar.tsx +++ b/src/components/titlebar/TitleBar.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { cn } from '@/lib/utils' import { isLinux, isMacOS, openExternal } from '@/lib/platform' import { Button } from '@/components/ui/button' @@ -219,35 +219,35 @@ export function TitleBar({ ) } -function triggerCliUpdate(update: PendingCliUpdate) { - const { openCliUpdateModal, openCliLoginModal, dismissCliUpdateNotice } = - useUIStore.getState() - - if (update.cliSource === 'path') { - const action = resolveCliPathUpdateAction( - update.type, - update.cliPath, - update.packageManager, - update.latestVersion - ) - if (action) { - openCliLoginModal(update.type, action[0], action[1], 'update') - } else { - toast.error( - `Can't auto-update ${CLI_DISPLAY_NAMES[update.type]}. Update it manually via your package manager.` - ) - return - } - } else { - openCliUpdateModal(update.type) - } - dismissCliUpdateNotice(update.type) -} - function CliUpdatesIndicator() { const updates = useUIStore(state => state.availableCliUpdates) + const dismissCliUpdateNotice = useUIStore(state => state.dismissCliUpdateNotice) + const openCliUpdateModal = useUIStore(state => state.openCliUpdateModal) + const openCliLoginModal = useUIStore(state => state.openCliLoginModal) const [open, setOpen] = useState(false) + const triggerUpdate = useCallback((update: PendingCliUpdate) => { + if (update.cliSource === 'path') { + const action = resolveCliPathUpdateAction( + update.type, + update.cliPath, + update.packageManager, + update.latestVersion + ) + if (action) { + openCliLoginModal(update.type, action[0], action[1], 'update') + } else { + toast.error( + `Can't auto-update ${CLI_DISPLAY_NAMES[update.type]}. Update it manually via your package manager.` + ) + return + } + } else { + openCliUpdateModal(update.type) + } + dismissCliUpdateNotice(update.type) + }, [dismissCliUpdateNotice, openCliUpdateModal, openCliLoginModal]) + // Auto-close popover when all updates have been acted on / dismissed useEffect(() => { if (updates.length === 0) setOpen(false) @@ -287,15 +287,13 @@ function CliUpdatesIndicator() {
-
+
-
+
{repoUrl && ( )} - + + )} +
+
) : showReviewFullWidth && activeSessionId ? (
=> { + async ( + session: Session, + forcePreparedContext = false + ): Promise => { const savedArgs = session.terminal_command_args ?? [] + if (forcePreparedContext) { + const preparedArgs = await prepareCommandArgs(session.id) + return [...savedArgs, ...preparedArgs] + } if (savedArgs.length === 0) { return prepareCommandArgs(session.id) } @@ -302,7 +316,10 @@ export function NativeCliSessionsModal({ ) const openTerminalSession = useCallback( - async (session: Session) => { + async ( + session: Session, + options?: { launchMode?: 'new' | 'resume'; trackNativeId?: boolean } + ) => { setOpeningSessionId(session.id) try { const terminalStore = useTerminalStore.getState() @@ -316,10 +333,47 @@ export function NativeCliSessionsModal({ let terminalId = existingTerminal?.id if (!terminalId) { - const commandArgs = await resolveTerminalCommandArgs(session) + const launchMode = options?.launchMode ?? 'resume' + const resumeLaunch = + launchMode === 'resume' + ? getNativeTerminalResumeLaunch(session) + : null + if ( + launchMode === 'resume' && + isNativeTerminalBackend(session.backend) && + !!session.terminal_command && + !resumeLaunch + ) { + toast.error('This legacy terminal session has no saved resume ID', { + description: + 'Choose the matching native session from this list instead.', + }) + return + } + + if (options?.trackNativeId && backend) { + try { + await invoke('track_native_cli_session', { + worktreePath, + sessionId: session.id, + backend, + }) + } catch (error) { + logger.error('Failed to start native CLI session tracking', { + backend, + sessionId: session.id, + error, + }) + toast.error('Session opened, but its resume ID may not be saved') + } + } + + const commandArgs = + resumeLaunch?.args ?? + (await resolveTerminalCommandArgs(session, launchMode === 'new')) terminalId = terminalStore.addTerminal( worktreeId, - session.terminal_command ?? command, + resumeLaunch?.command ?? session.terminal_command ?? command, session.terminal_label ?? session.name, { kind: 'session', @@ -353,7 +407,10 @@ export function NativeCliSessionsModal({ ) const createNewSession = useCallback(() => { - const commandArgs = initialCommandArgs + const nativeSessionId = backend === 'claude' ? generateId() : undefined + const commandArgs = nativeSessionId + ? [...initialCommandArgs, '--session-id', nativeSessionId] + : initialCommandArgs createSession.mutate( { worktreeId, @@ -364,16 +421,27 @@ export function NativeCliSessionsModal({ terminalCommand: command, terminalCommandArgs: commandArgs, terminalLabel: label, + nativeSessionId, }, { onSuccess: session => { - void openTerminalSession({ - ...session, - primary_surface: 'terminal', - terminal_command: command, - terminal_command_args: commandArgs, - terminal_label: label, - }) + void openTerminalSession( + { + ...session, + primary_surface: 'terminal', + terminal_command: command, + terminal_command_args: commandArgs, + terminal_label: label, + claude_session_id: + backend === 'claude' + ? nativeSessionId + : session.claude_session_id, + }, + { + launchMode: 'new', + trackNativeId: backend === 'codex' || backend === 'opencode', + } + ) }, } ) @@ -420,16 +488,32 @@ export function NativeCliSessionsModal({ terminalCommand: command, terminalCommandArgs: resumeArgs, terminalLabel: nativeSession.title, + nativeSessionId: nativeSession.id, }, { onSuccess: session => { - void openTerminalSession({ - ...session, - primary_surface: 'terminal', - terminal_command: command, - terminal_command_args: resumeArgs, - terminal_label: nativeSession.title, - }) + void openTerminalSession( + { + ...session, + primary_surface: 'terminal', + terminal_command: command, + terminal_command_args: resumeArgs, + terminal_label: nativeSession.title, + claude_session_id: + backend === 'claude' + ? nativeSession.id + : session.claude_session_id, + codex_thread_id: + backend === 'codex' + ? nativeSession.id + : session.codex_thread_id, + opencode_session_id: + backend === 'opencode' + ? nativeSession.id + : session.opencode_session_id, + }, + { launchMode: 'resume' } + ) }, } ) @@ -545,7 +629,11 @@ export function NativeCliSessionsModal({ disabled={ openingSessionId !== null || createSession.isPending } - onClick={() => void openTerminalSession(session)} + onClick={() => + void openTerminalSession(session, { + launchMode: 'resume', + }) + } className={cn( 'flex w-full min-w-0 items-start gap-3 rounded-lg border border-border/70 bg-muted/25 px-3.5 py-3 text-left transition-colors', 'hover:border-border hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', diff --git a/src/components/chat/NewSessionModeModal.test.tsx b/src/components/chat/NewSessionModeModal.test.tsx index 7ead763fd..60db3f0ad 100644 --- a/src/components/chat/NewSessionModeModal.test.tsx +++ b/src/components/chat/NewSessionModeModal.test.tsx @@ -10,6 +10,7 @@ const mutate = vi.fn() const invoke = vi.fn() let sessionsData: { sessions: unknown[] } let nativeSessionsData: unknown[] +let opencodeInstalled: boolean let cursorInstalled: boolean let commandCodeInstalled: boolean let grokInstalled: boolean @@ -64,7 +65,10 @@ vi.mock('@/services/codex-cli', () => ({ vi.mock('@/services/opencode-cli', () => ({ useOpencodeCliStatus: () => ({ - data: { installed: false, path: null }, + data: { + installed: opencodeInstalled, + path: opencodeInstalled ? '/usr/local/bin/opencode' : null, + }, isLoading: false, }), })) @@ -106,6 +110,7 @@ describe('NewSessionModeModal', () => { invoke.mockReset() sessionsData = { sessions: [] } nativeSessionsData = [] + opencodeInstalled = false cursorInstalled = false commandCodeInstalled = false grokInstalled = false @@ -323,6 +328,11 @@ describe('NewSessionModeModal', () => { expect.any(Object) ) await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('track_native_cli_session', { + worktreePath: '/tmp/worktree-1', + sessionId: 'session-terminal-1', + backend: 'codex', + }) expect(invoke).toHaveBeenCalledWith('prepare_backend_terminal_context', { sessionId: 'session-terminal-1', worktreeId: 'worktree-1', @@ -394,19 +404,32 @@ describe('NewSessionModeModal', () => { fireEvent.click(screen.getByText('New Claude session')) + const claudeCreateArgs = mutate.mock.calls[0]?.[0] as { + nativeSessionId: string + terminalCommandArgs: string[] + } expect(mutate).toHaveBeenCalledWith( - { + expect.objectContaining({ worktreeId: 'worktree-1', worktreePath: '/tmp/worktree-1', name: 'Claude', backend: 'claude', primarySurface: 'terminal', terminalCommand: '/usr/local/bin/claude', - terminalCommandArgs: ['--permission-mode', 'bypassPermissions'], terminalLabel: 'Claude', - }, + nativeSessionId: expect.any(String), + terminalCommandArgs: [ + '--permission-mode', + 'bypassPermissions', + '--session-id', + expect.any(String), + ], + }), expect.any(Object) ) + expect(claudeCreateArgs.terminalCommandArgs.at(-1)).toBe( + claudeCreateArgs.nativeSessionId + ) await waitFor(() => { expect(invoke).toHaveBeenCalledWith('prepare_backend_terminal_context', { sessionId: 'session-claude-yolo', @@ -421,6 +444,8 @@ describe('NewSessionModeModal', () => { commandArgs: [ '--permission-mode', 'bypassPermissions', + '--session-id', + claudeCreateArgs.nativeSessionId, '--context-arg', 'context-value', ], @@ -486,6 +511,52 @@ describe('NewSessionModeModal', () => { }) }) + it('tracks a new OpenCode terminal session before launching it', async () => { + opencodeInstalled = true + mutate.mockImplementation( + ( + _args: unknown, + opts?: { + onSuccess?: (session: { + id: string + name: string + backend?: string + }) => void + } + ) => { + opts?.onSuccess?.({ + id: 'session-opencode', + name: 'OpenCode', + backend: 'opencode', + }) + } + ) + useUIStore.getState().openNewSessionModeModal({ + worktreeId: 'worktree-1', + worktreePath: '/tmp/worktree-1', + origin: 'chat', + }) + + render() + fireEvent.click(screen.getByText('OpenCode')) + fireEvent.click(screen.getByText('New OpenCode session')) + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('track_native_cli_session', { + worktreePath: '/tmp/worktree-1', + sessionId: 'session-opencode', + backend: 'opencode', + }) + }) + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + backend: 'opencode', + terminalCommand: '/usr/local/bin/opencode', + }), + expect.any(Object) + ) + }) + it('opens a plain terminal session with shortcut 1', async () => { mutate.mockImplementation( ( @@ -565,7 +636,7 @@ describe('NewSessionModeModal', () => { ) }) - it('continues an existing native CLI terminal session without creating a new one', async () => { + it('does not relaunch a legacy native CLI session without a resume ID', async () => { const expectedUpdatedAt = new Date(1710000000 * 1000).toLocaleString( undefined, { @@ -607,19 +678,14 @@ describe('NewSessionModeModal', () => { expect(mutate).not.toHaveBeenCalled() await waitFor(() => { - expect(invoke).toHaveBeenCalledWith('prepare_backend_terminal_context', { - sessionId: 'existing-codex-session', - worktreeId: 'worktree-1', - backend: 'codex', - }) + expect( + useTerminalStore.getState().terminals['worktree-1'] ?? [] + ).toHaveLength(0) }) - expect(useTerminalStore.getState().terminals['worktree-1']).toHaveLength(1) - expect(useChatStore.getState().activeSessionIds['worktree-1']).toBe( - 'existing-codex-session' + expect(invoke).not.toHaveBeenCalledWith( + 'prepare_backend_terminal_context', + expect.any(Object) ) - expect( - useChatStore.getState().selectedBackends['existing-codex-session'] - ).toBe('codex') }) it('imports native Codex history into a Jean terminal session', async () => { @@ -678,6 +744,7 @@ describe('NewSessionModeModal', () => { terminalCommand: '/usr/local/bin/codex', terminalCommandArgs: ['resume', 'native-codex-thread'], terminalLabel: 'Native Codex task', + nativeSessionId: 'native-codex-thread', }, expect.any(Object) ) diff --git a/src/components/chat/session-card-utils.tsx b/src/components/chat/session-card-utils.tsx index 3dd810559..af6b7a3aa 100644 --- a/src/components/chat/session-card-utils.tsx +++ b/src/components/chat/session-card-utils.tsx @@ -12,6 +12,7 @@ import { type PermissionDenial, type LabelData, } from '@/types/chat' +import { getNativeTerminalResumeLaunch } from '@/lib/native-cli-session' import { findPlanFilePath, resolvePlanContent } from './tool-call-utils' export type SessionStatus = @@ -421,24 +422,8 @@ export function getResumeArgs( session: Session ): { command: string; args: string[] } | null { const cmd = session.terminal_command || '' - if (session.backend === 'claude' && session.claude_session_id) { - return { - command: cmd || 'claude', - args: ['--resume', session.claude_session_id], - } - } - if (session.backend === 'codex' && session.codex_thread_id) { - return { - command: cmd || 'codex', - args: ['resume', session.codex_thread_id], - } - } - if (session.backend === 'opencode' && session.opencode_session_id) { - return { - command: cmd || 'opencode', - args: ['-s', session.opencode_session_id], - } - } + const nativeLaunch = getNativeTerminalResumeLaunch(session) + if (nativeLaunch) return nativeLaunch if (session.backend === 'cursor' && session.cursor_chat_id) { return { command: cmd || 'cursor-agent', diff --git a/src/lib/native-cli-session.test.ts b/src/lib/native-cli-session.test.ts new file mode 100644 index 000000000..dcbe8fca9 --- /dev/null +++ b/src/lib/native-cli-session.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { Session } from '@/types/chat' +import { + buildNativeResumeArgs, + getNativeTerminalResumeLaunch, + hasLegacyNativeResumeArgs, +} from './native-cli-session' + +const session = (overrides: Partial): Session => + ({ + id: 'session-1', + name: 'Native CLI', + order: 0, + created_at: 1, + updated_at: 1, + messages: [], + version: 2, + primary_surface: 'terminal', + ...overrides, + }) as Session + +describe('buildNativeResumeArgs', () => { + it('preserves Claude permission flags and replaces launch session IDs', () => { + expect( + buildNativeResumeArgs('claude', 'claude-session', [ + '--permission-mode', + 'bypassPermissions', + '--session-id', + 'initial-session', + ]) + ).toEqual([ + '--permission-mode', + 'bypassPermissions', + '--resume', + 'claude-session', + ]) + }) + + it('preserves Codex global flags before the resume subcommand', () => { + expect( + buildNativeResumeArgs('codex', 'codex-thread', [ + '--dangerously-bypass-approvals-and-sandbox', + 'resume', + 'old-thread', + ]) + ).toEqual([ + '--dangerously-bypass-approvals-and-sandbox', + 'resume', + 'codex-thread', + ]) + }) + + it('replaces OpenCode session selectors', () => { + expect( + buildNativeResumeArgs('opencode', 'opencode-session', [ + '--model', + 'anthropic/claude-sonnet-4-6', + '-s', + 'old-session', + ]) + ).toEqual([ + '--model', + 'anthropic/claude-sonnet-4-6', + '--session', + 'opencode-session', + ]) + }) +}) + +describe('getNativeTerminalResumeLaunch', () => { + it('builds a typed Claude resume launch', () => { + expect( + getNativeTerminalResumeLaunch( + session({ + backend: 'claude', + terminal_command: '/usr/local/bin/claude', + terminal_command_args: ['--session-id', 'claude-session'], + claude_session_id: 'claude-session', + }) + ) + ).toEqual({ + command: '/usr/local/bin/claude', + args: ['--resume', 'claude-session'], + }) + }) + + it('keeps legacy persisted resume arguments', () => { + const legacy = session({ + backend: 'codex', + terminal_command: '/usr/local/bin/codex', + terminal_command_args: ['resume', 'legacy-thread'], + }) + + expect(hasLegacyNativeResumeArgs(legacy)).toBe(true) + expect(getNativeTerminalResumeLaunch(legacy)).toEqual({ + command: '/usr/local/bin/codex', + args: ['resume', 'legacy-thread'], + }) + }) + + it('refuses to relaunch a native terminal without a resume ID', () => { + expect( + getNativeTerminalResumeLaunch( + session({ + backend: 'opencode', + terminal_command: '/usr/local/bin/opencode', + terminal_command_args: [], + }) + ) + ).toBeNull() + }) +}) diff --git a/src/lib/native-cli-session.ts b/src/lib/native-cli-session.ts new file mode 100644 index 000000000..c2fdacf53 --- /dev/null +++ b/src/lib/native-cli-session.ts @@ -0,0 +1,136 @@ +import type { Session } from '@/types/chat' + +export type NativeTerminalBackend = 'claude' | 'codex' | 'opencode' + +export interface NativeTerminalLaunch { + command: string + args: string[] +} + +export function isNativeTerminalBackend( + backend: Session['backend'] +): backend is NativeTerminalBackend { + return backend === 'claude' || backend === 'codex' || backend === 'opencode' +} + +export function getNativeSessionId(session: Session): string | null { + if (session.backend === 'claude') { + return session.claude_session_id ?? null + } + if (session.backend === 'codex') { + return session.codex_thread_id ?? null + } + if (session.backend === 'opencode') { + return session.opencode_session_id ?? null + } + return null +} + +function stripClaudeSessionArgs(args: string[]): string[] { + const result: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--resume' || arg === '-r' || arg === '--session-id') { + index += 1 + continue + } + if (arg?.startsWith('--resume=') || arg?.startsWith('--session-id=')) { + continue + } + if (arg) result.push(arg) + } + return result +} + +function stripCodexSessionArgs(args: string[]): string[] { + const resumeIndex = args.indexOf('resume') + return resumeIndex >= 0 ? args.slice(0, resumeIndex) : [...args] +} + +function stripOpenCodeSessionArgs(args: string[]): string[] { + const result: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--session' || arg === '-s') { + index += 1 + continue + } + if (arg === '--continue' || arg === '-c' || arg?.startsWith('--session=')) { + continue + } + if (arg) result.push(arg) + } + return result +} + +export function buildNativeResumeArgs( + backend: NativeTerminalBackend, + nativeSessionId: string, + persistedArgs: string[] = [] +): string[] { + if (backend === 'claude') { + return [ + ...stripClaudeSessionArgs(persistedArgs), + '--resume', + nativeSessionId, + ] + } + if (backend === 'codex') { + return [...stripCodexSessionArgs(persistedArgs), 'resume', nativeSessionId] + } + return [ + ...stripOpenCodeSessionArgs(persistedArgs), + '--session', + nativeSessionId, + ] +} + +export function hasLegacyNativeResumeArgs(session: Session): boolean { + const args = session.terminal_command_args ?? [] + if (session.backend === 'claude') { + return args.some( + arg => arg === '--resume' || arg === '-r' || arg.startsWith('--resume=') + ) + } + if (session.backend === 'codex') { + return args.includes('resume') + } + if (session.backend === 'opencode') { + return args.some( + arg => arg === '--session' || arg === '-s' || arg.startsWith('--session=') + ) + } + return false +} + +export function getNativeTerminalResumeLaunch( + session: Session +): NativeTerminalLaunch | null { + if (!isNativeTerminalBackend(session.backend)) { + return null + } + + const command = session.terminal_command + if (!command) return null + + const nativeSessionId = getNativeSessionId(session) + if (nativeSessionId) { + return { + command, + args: buildNativeResumeArgs( + session.backend, + nativeSessionId, + session.terminal_command_args ?? [] + ), + } + } + + if (hasLegacyNativeResumeArgs(session)) { + return { + command, + args: session.terminal_command_args ?? [], + } + } + + return null +} diff --git a/src/services/chat.test.ts b/src/services/chat.test.ts index d112b8244..7e0f45180 100644 --- a/src/services/chat.test.ts +++ b/src/services/chat.test.ts @@ -17,7 +17,11 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })) -import { prefetchSessions, reconnectNativeCliSession } from './chat' +import { + canReconnectSession, + prefetchSessions, + reconnectNativeCliSession, +} from './chat' import { useChatStore } from '@/store/chat-store' import { useUIStore } from '@/store/ui-store' import { useTerminalStore } from '@/store/terminal-store' @@ -137,6 +141,42 @@ describe('reconnectNativeCliSession', () => { expect(terminal?.commandArgs).toEqual(['--resume', 'abc123']) }) + it('preserves native CLI global flags when resuming', async () => { + await reconnectNativeCliSession( + { + ...terminalSession, + terminal_command_args: [ + '--permission-mode', + 'bypassPermissions', + '--session-id', + 'abc123', + ], + }, + 'wt-1' + ) + + const terminalId = useUIStore.getState().sessionTerminalIds['session-1'] + const terminal = useTerminalStore + .getState() + .terminals['wt-1']?.find(t => t.id === terminalId) + expect(terminal?.commandArgs).toEqual([ + '--permission-mode', + 'bypassPermissions', + '--resume', + 'abc123', + ]) + }) + + it('refuses to reconnect native sessions without a persisted resume ID', () => { + expect( + canReconnectSession({ + ...terminalSession, + claude_session_id: undefined, + terminal_command_args: ['--permission-mode', 'bypassPermissions'], + }) + ).toBe(false) + }) + it('opens the modal drawer and toasts by default (manual reconnect)', async () => { await reconnectNativeCliSession(terminalSession, 'wt-1') @@ -154,7 +194,9 @@ describe('reconnectNativeCliSession', () => { // Terminal still restored... expect(useUIStore.getState().sessionTerminalIds['session-1']).toBeDefined() // ...but no floating drawer pops and no toast fires. - expect(useTerminalStore.getState().modalTerminalOpen['wt-1']).toBeUndefined() + expect( + useTerminalStore.getState().modalTerminalOpen['wt-1'] + ).toBeUndefined() expect(toastMock.success).not.toHaveBeenCalled() }) diff --git a/src/services/chat.ts b/src/services/chat.ts index 8b77c65d6..261962db7 100644 --- a/src/services/chat.ts +++ b/src/services/chat.ts @@ -37,6 +37,7 @@ import type { AppPreferences } from '@/types/preferences' import { useChatStore } from '@/store/chat-store' import { useUIStore } from '@/store/ui-store' import { useTerminalStore } from '@/store/terminal-store' +import { isNativeTerminalBackend } from '@/lib/native-cli-session' import { getResumeArgs } from '@/components/chat/session-card-utils' import type { ReviewResponse, Worktree } from '@/types/projects' @@ -98,10 +99,9 @@ export function removeSessionFromAllSessionsCache( * terminal command). Used to gate the "Reconnect" menu item. */ export function canReconnectSession(session: Session): boolean { - return ( - session.primary_surface === 'terminal' && - (!!getResumeArgs(session) || !!session.terminal_command) - ) + if (session.primary_surface !== 'terminal') return false + if (getResumeArgs(session)) return true + return !isNativeTerminalBackend(session.backend) && !!session.terminal_command } /** @@ -131,13 +131,21 @@ export async function reconnectNativeCliSession( worktreeId: string, options?: { openModal?: boolean; showToast?: boolean; markOpened?: boolean } ): Promise { - const { openModal = true, showToast = true, markOpened = true } = options ?? {} + const { + openModal = true, + showToast = true, + markOpened = true, + } = options ?? {} const resume = getResumeArgs(session) - const launch = resume ?? { - command: session.terminal_command ?? '', - args: session.terminal_command_args ?? [], - } - if (!launch.command) { + const launch = + resume ?? + (!isNativeTerminalBackend(session.backend) + ? { + command: session.terminal_command ?? '', + args: session.terminal_command_args ?? [], + } + : null) + if (!launch?.command) { if (showToast) toast.error('No command available to reconnect this session') return } @@ -732,6 +740,7 @@ export function useCreateSession() { terminalCommand, terminalCommandArgs, terminalLabel, + nativeSessionId, }: { worktreeId: string worktreePath: string @@ -741,6 +750,7 @@ export function useCreateSession() { terminalCommand?: string | null terminalCommandArgs?: string[] terminalLabel?: string + nativeSessionId?: string }): Promise => { if (!isTauri()) { throw new Error('Not in Tauri context') @@ -756,6 +766,7 @@ export function useCreateSession() { terminalCommand, terminalCommandArgs, terminalLabel, + nativeSessionId, }) logger.info('Session created', { sessionId: session.id }) return session From e3815f6d2e66ae3c8684ed3f1457cb3ab5ab47e2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:38:37 +0200 Subject: [PATCH 007/359] fix(web-access): use server platform for browser sessions Expose the Jean server platform during web init and use it for Windows-specific onboarding, shell escaping, and editor/terminal options. Keep Web Access settings available in browser mode while hiding native file-manager actions outside the desktop app. --- src-tauri/src/http_server/server.rs | 19 +++++++ src/App.tsx | 5 ++ .../chat/hooks/useGitOperations.test.tsx | 2 + .../dashboard/ProjectCanvasView.tsx | 16 ++++-- src/components/magic/MagicModal.test.tsx | 2 + .../onboarding/OnboardingDialog.tsx | 6 +- src/components/open-in/OpenInModal.test.tsx | 16 +++++- .../preferences/panes/GeneralPane.tsx | 8 +-- .../preferences/panes/WebAccessPane.test.tsx | 57 +++++++++++++++++++ .../preferences/panes/WebAccessPane.tsx | 16 ------ .../projects/ProjectContextMenu.tsx | 11 ++-- src/lib/platform.test.ts | 18 ++++++ src/lib/platform.ts | 22 +++++++ src/lib/shell-escape.test.ts | 18 ++++++ src/lib/shell-escape.ts | 5 +- src/lib/transport.ts | 1 + src/services/preferences.test.ts | 3 + src/types/preferences-platform.test.ts | 18 ++++++ src/types/preferences.ts | 24 +++++--- 19 files changed, 224 insertions(+), 43 deletions(-) create mode 100644 src/components/preferences/panes/WebAccessPane.test.tsx create mode 100644 src/lib/shell-escape.test.ts create mode 100644 src/types/preferences-platform.test.ts diff --git a/src-tauri/src/http_server/server.rs b/src-tauri/src/http_server/server.rs index 483e47731..01e2709a0 100644 --- a/src-tauri/src/http_server/server.rs +++ b/src-tauri/src/http_server/server.rs @@ -118,6 +118,16 @@ impl Default for WebBuildInfo { } } +fn server_platform_name() -> &'static str { + if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "mac" + } else { + "linux" + } +} + async fn read_web_build_info(dist_path: &std::path::Path) -> WebBuildInfo { let path = dist_path.join("jean-build.json"); match tokio::fs::read_to_string(&path).await { @@ -597,6 +607,7 @@ async fn init_handler(Query(params): Query, State(state): State projects, @@ -1577,6 +1588,14 @@ mod tests { ); } + #[test] + fn server_platform_name_matches_supported_frontend_values() { + assert!(matches!( + super::server_platform_name(), + "mac" | "windows" | "linux" + )); + } + #[test] fn test_path_is_in_known_roots_allows_nested_project_file() { let dir = tempfile::tempdir().expect("temp dir"); diff --git a/src/App.tsx b/src/App.tsx index 230de0551..fd776ee5c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { type InitialData, } from '@/lib/transport' import { isNativeApp } from '@/lib/environment' +import { setServerPlatform } from '@/lib/platform' import { projectsQueryKeys } from '@/services/projects' import { chatQueryKeys } from '@/services/chat' import type { Session, WorktreeSessions } from '@/types/chat' @@ -243,6 +244,10 @@ function App() { message: Session['messages'][number] }[] = [] + if (data.serverPlatform) { + setServerPlatform(data.serverPlatform) + } + // Seed projects into TanStack Query cache if (data.projects) { queryClient.setQueryData(projectsQueryKeys.list(), data.projects) diff --git a/src/components/chat/hooks/useGitOperations.test.tsx b/src/components/chat/hooks/useGitOperations.test.tsx index 56e95720c..dd6c0c7cd 100644 --- a/src/components/chat/hooks/useGitOperations.test.tsx +++ b/src/components/chat/hooks/useGitOperations.test.tsx @@ -43,6 +43,8 @@ vi.mock('@/lib/platform', () => ({ isMacOS: false, isWindows: false, isLinux: true, + getServerPlatform: vi.fn(() => 'linux'), + isServerWindows: vi.fn(() => false), })) const ref = (current: T): RefObject => ({ current }) diff --git a/src/components/dashboard/ProjectCanvasView.tsx b/src/components/dashboard/ProjectCanvasView.tsx index 1dccb645b..4d5b8b40f 100644 --- a/src/components/dashboard/ProjectCanvasView.tsx +++ b/src/components/dashboard/ProjectCanvasView.tsx @@ -20,6 +20,7 @@ import { import { useQueries, useQueryClient } from '@tanstack/react-query' import { invoke } from '@/lib/transport' import { cn } from '@/lib/utils' +import { isNativeApp } from '@/lib/environment' import { dismissibleToast } from '@/lib/dismissible-toast' import { Search, @@ -854,6 +855,7 @@ export function ProjectCanvasView({ projectId }: ProjectCanvasViewProps) { const [searchQuery, setSearchQuery] = useState('') const [activeFilterTab, setActiveFilterTab] = useState('all') const isMobile = useIsMobile() + const isNative = isNativeApp() const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false) const showWorktreeLabelContextMenu = shouldShowWorktreeLabelContextMenu({ isMobile, @@ -3018,12 +3020,14 @@ export function ProjectCanvasView({ projectId }: ProjectCanvasViewProps) { Open in {getEditorLabel(preferences?.editor)} - openInFinder.mutate(project.path)} - > - - Open in Finder - + {isNative && ( + openInFinder.mutate(project.path)} + > + + Open in Finder + + )} diff --git a/src/components/magic/MagicModal.test.tsx b/src/components/magic/MagicModal.test.tsx index eee998cbd..56e0b009c 100644 --- a/src/components/magic/MagicModal.test.tsx +++ b/src/components/magic/MagicModal.test.tsx @@ -203,6 +203,8 @@ vi.mock('@/lib/platform', () => ({ isMacOS: false, isWindows: false, isLinux: true, + getServerPlatform: vi.fn(() => 'linux'), + isServerWindows: vi.fn(() => false), })) vi.mock('@tanstack/react-query', async importOriginal => ({ ...(await importOriginal()), diff --git a/src/components/onboarding/OnboardingDialog.tsx b/src/components/onboarding/OnboardingDialog.tsx index eb97f644c..f9162f951 100644 --- a/src/components/onboarding/OnboardingDialog.tsx +++ b/src/components/onboarding/OnboardingDialog.tsx @@ -78,7 +78,7 @@ import { type MagicPromptBackends, type MagicPromptModels, } from '@/types/preferences' -import { isWindows } from '@/lib/platform' +import { isServerWindows } from '@/lib/platform' import { WslSetupStep } from './WslSetupStep' import { ArrowLeft, Loader2 } from 'lucide-react' @@ -733,7 +733,7 @@ function OnboardingDialogContent() { // On Windows, show WSL mode selection first if not yet chosen if ( - isWindows && + isServerWindows() && preferences && !preferences.wsl_mode_chosen && !onboardingStartStep @@ -778,7 +778,7 @@ function OnboardingDialogContent() { // can change their WSL/native choice, then backend-select (via Continue // on the WSL step). Non-Windows goes straight to backend-select. if (onboardingManuallyTriggered) { - const firstStep: OnboardingStep = isWindows + const firstStep: OnboardingStep = isServerWindows() ? 'wsl-setup' : 'backend-select' dbg('init effect: manual trigger →', firstStep) diff --git a/src/components/open-in/OpenInModal.test.tsx b/src/components/open-in/OpenInModal.test.tsx index e7f86bd79..126c1e588 100644 --- a/src/components/open-in/OpenInModal.test.tsx +++ b/src/components/open-in/OpenInModal.test.tsx @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { render, screen } from '@/test/test-utils' import { OpenInModal } from './OpenInModal' +const nativeState = vi.hoisted(() => ({ value: true })) + const mocks = vi.hoisted(() => ({ setOpenInModalOpen: vi.fn(), openPreferencesPane: vi.fn(), @@ -74,7 +76,7 @@ vi.mock('@/services/preferences', () => ({ })) vi.mock('@/lib/environment', () => ({ - isNativeApp: () => true, + isNativeApp: () => nativeState.value, })) vi.mock('@/lib/platform', () => ({ @@ -82,6 +84,8 @@ vi.mock('@/lib/platform', () => ({ isMacOS: true, isWindows: false, isLinux: false, + getServerPlatform: vi.fn(() => 'mac'), + isServerWindows: vi.fn(() => false), })) vi.mock('@/lib/transport', () => ({ @@ -153,6 +157,16 @@ vi.mock('@/services/github', () => ({ describe('OpenInModal', () => { beforeEach(() => { vi.clearAllMocks() + nativeState.value = true + }) + + it('hides Finder in browser/headless mode', async () => { + nativeState.value = false + + render() + + expect(await screen.findByText('GitHub')).toBeInTheDocument() + expect(screen.queryByText('Finder')).not.toBeInTheDocument() }) it('shows worktree and loaded security/advisory context URLs', async () => { diff --git a/src/components/preferences/panes/GeneralPane.tsx b/src/components/preferences/panes/GeneralPane.tsx index 50cde31ed..818bb490a 100644 --- a/src/components/preferences/panes/GeneralPane.tsx +++ b/src/components/preferences/panes/GeneralPane.tsx @@ -140,8 +140,8 @@ import { effortLevelOptions, codexReasoningOptions, backendOptions, - terminalOptions, - editorOptions, + getTerminalOptions, + getEditorOptions, gitPollIntervalOptions, remotePollIntervalOptions, archiveRetentionOptions, @@ -3938,7 +3938,7 @@ export const GeneralPane: React.FC<{ scope?: PreferencesPaneScope }> = ({ - {editorOptions.map(option => ( + {getEditorOptions().map(option => ( {option.label} @@ -3961,7 +3961,7 @@ export const GeneralPane: React.FC<{ scope?: PreferencesPaneScope }> = ({ - {terminalOptions.map(option => ( + {getTerminalOptions().map(option => ( {option.label} diff --git a/src/components/preferences/panes/WebAccessPane.test.tsx b/src/components/preferences/panes/WebAccessPane.test.tsx new file mode 100644 index 000000000..d4395fb43 --- /dev/null +++ b/src/components/preferences/panes/WebAccessPane.test.tsx @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@/test/test-utils' +import { WebAccessPane } from './WebAccessPane' +import { defaultPreferences } from '@/types/preferences' + +const invokeMock = vi.fn() + +vi.mock('@/lib/transport', () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})) + +vi.mock('@/lib/environment', () => ({ + isNativeApp: () => false, + hasBackend: () => true, +})) + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn() })) +vi.mock('@/lib/platform', () => ({ + isMacOS: false, + isWindows: false, + isLinux: true, + getServerPlatform: vi.fn(() => 'linux'), + isServerWindows: vi.fn(() => false), + openExternal: vi.fn(), +})) + +describe('WebAccessPane in browser/headless mode', () => { + beforeEach(() => { + vi.clearAllMocks() + invokeMock.mockImplementation((command: string) => { + if (command === 'load_preferences') return Promise.resolve(defaultPreferences) + if (command === 'get_http_server_status') { + return Promise.resolve({ + running: true, + port: 3456, + url: 'http://0.0.0.0:3456', + token: 'secret-token', + bind_host: '0.0.0.0', + localhost_only: false, + }) + } + if (command === 'list_http_bind_host_options') return Promise.resolve([]) + return Promise.resolve(null) + }) + }) + + it('shows web access controls in browser mode so headless users can configure it', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Enable HTTP server')).toBeInTheDocument() + }) + expect(screen.queryByText(/only available in the desktop app/i)).not.toBeInTheDocument() + expect(screen.getByDisplayValue('secret-token')).toBeInTheDocument() + }) +}) diff --git a/src/components/preferences/panes/WebAccessPane.tsx b/src/components/preferences/panes/WebAccessPane.tsx index b05e2d35e..32420683d 100644 --- a/src/components/preferences/panes/WebAccessPane.tsx +++ b/src/components/preferences/panes/WebAccessPane.tsx @@ -27,7 +27,6 @@ import { usePreferences, usePatchPreferences } from '@/services/preferences' import type { AppPreferences } from '@/types/preferences' import { invoke } from '@/lib/transport' import { toast } from 'sonner' -import { isNativeApp } from '@/lib/environment' import { openExternal } from '@/lib/platform' import { copyToClipboard } from '@/lib/clipboard' import { SettingsSection } from '../SettingsSection' @@ -115,7 +114,6 @@ export const WebAccessPane: React.FC = () => { // Poll server status const refreshStatus = useCallback(async () => { - if (!isNativeApp()) return try { const status = await invoke('get_http_server_status') setServerStatus(status) @@ -131,8 +129,6 @@ export const WebAccessPane: React.FC = () => { }, [refreshStatus]) useEffect(() => { - if (!isNativeApp()) return - let cancelled = false const loadBindHostOptions = async () => { try { @@ -345,18 +341,6 @@ export const WebAccessPane: React.FC = () => { const showBoundUrl = hasUsableBoundUrl(boundUrl) && !isLoopbackBindHost(activeBindHost) - if (!isNativeApp()) { - return ( -
-
-

- Web Access settings are only available in the desktop app. -

-
-
- ) - } - return (

diff --git a/src/components/projects/ProjectContextMenu.tsx b/src/components/projects/ProjectContextMenu.tsx index 50995610a..57c3532e2 100644 --- a/src/components/projects/ProjectContextMenu.tsx +++ b/src/components/projects/ProjectContextMenu.tsx @@ -34,6 +34,7 @@ import { useProjectsStore } from '@/store/projects-store' import { useUIStore } from '@/store/ui-store' import { getEditorLabel, getTerminalLabel } from '@/types/preferences' import { getFileManagerName } from '@/lib/platform' +import { isNativeApp } from '@/lib/environment' interface ProjectContextMenuProps { project: Project @@ -144,10 +145,12 @@ export function ProjectContextMenu({ Open in {getEditorLabel(preferences?.editor)} - - - Open in {getFileManagerName()} - + {isNativeApp() && ( + + + Open in {getFileManagerName()} + + )} diff --git a/src/lib/platform.test.ts b/src/lib/platform.test.ts index 7dd044327..dd081ad50 100644 --- a/src/lib/platform.test.ts +++ b/src/lib/platform.test.ts @@ -55,3 +55,21 @@ describe('openExternal', () => { expect(openUrlMock).not.toHaveBeenCalled() }) }) + +describe('server platform detection', () => { + it('uses the Jean server platform instead of the browser platform when provided', async () => { + vi.stubGlobal('window', { open: vi.fn() }) + vi.stubGlobal('navigator', { platform: 'Win32' }) + + const { getServerPlatform, isServerWindows, setServerPlatform } = + await import('./platform') + + setServerPlatform('linux') + + expect(getServerPlatform()).toBe('linux') + expect(isServerWindows()).toBe(false) + + setServerPlatform('windows') + expect(isServerWindows()).toBe(true) + }) +}) diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 4b4493a92..80996363c 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -5,6 +5,28 @@ export const isMacOS = navigator.platform.includes('Mac') export const isWindows = navigator.platform.includes('Win') export const isLinux = navigator.platform.includes('Linux') +export type PlatformName = 'mac' | 'windows' | 'linux' + +function browserPlatform(): PlatformName { + if (isMacOS) return 'mac' + if (isWindows) return 'windows' + return 'linux' +} + +let serverPlatform: PlatformName = browserPlatform() + +export function setServerPlatform(platform: PlatformName): void { + serverPlatform = platform +} + +export function getServerPlatform(): PlatformName { + return serverPlatform +} + +export function isServerWindows(): boolean { + return serverPlatform === 'windows' +} + /** * Pre-open a blank browser tab synchronously during a user gesture. * On mobile/web, calling window.open() after an async operation (e.g. WebSocket invoke) diff --git a/src/lib/shell-escape.test.ts b/src/lib/shell-escape.test.ts new file mode 100644 index 000000000..289b6e9bc --- /dev/null +++ b/src/lib/shell-escape.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { setServerPlatform } from './platform' +import { escapeCliCommand } from './shell-escape' + +describe('escapeCliCommand', () => { + it('uses the Jean server platform instead of the browser user agent', () => { + setServerPlatform('linux') + + expect(escapeCliCommand('/home/me/my cli', '--help')).toBe( + "'/home/me/my cli' --help" + ) + + setServerPlatform('windows') + expect(escapeCliCommand('C:\\Tools\\my cli.exe', '--help')).toBe( + '& "C:\\Tools\\my cli.exe" --help' + ) + }) +}) diff --git a/src/lib/shell-escape.ts b/src/lib/shell-escape.ts index 3fd1b9b8b..dad1f4c7a 100644 --- a/src/lib/shell-escape.ts +++ b/src/lib/shell-escape.ts @@ -4,9 +4,10 @@ * - Windows (PowerShell): `& "path" args` * - Unix (sh/zsh/bash): `'path' args` (with internal single-quote escaping) */ +import { isServerWindows } from './platform' + export function escapeCliCommand(path: string, args?: string): string { - const isWindows = navigator.userAgent.includes('Windows') - if (isWindows) { + if (isServerWindows()) { return args ? `& "${path}" ${args}` : `& "${path}"` } const escaped = `'${path.replace(/'/g, "'\\''")}'` diff --git a/src/lib/transport.ts b/src/lib/transport.ts index cb718762d..f1e681f21 100644 --- a/src/lib/transport.ts +++ b/src/lib/transport.ts @@ -173,6 +173,7 @@ export interface InitialData { preferences?: unknown uiState?: unknown appDataDir?: string + serverPlatform?: 'mac' | 'windows' | 'linux' webBuildId?: string appVersion?: string } diff --git a/src/services/preferences.test.ts b/src/services/preferences.test.ts index 31c49176c..9d3d1a855 100644 --- a/src/services/preferences.test.ts +++ b/src/services/preferences.test.ts @@ -43,6 +43,8 @@ vi.mock('@/lib/platform', () => ({ isMacOS: true, isWindows: false, isLinux: false, + getServerPlatform: vi.fn(() => 'mac'), + isServerWindows: vi.fn(() => false), getModifierSymbol: vi.fn(() => '⌘'), getFileManagerName: vi.fn(() => 'Finder'), openExternal: vi.fn(), @@ -1200,3 +1202,4 @@ describe('preferences service', () => { }) }) }) + diff --git a/src/types/preferences-platform.test.ts b/src/types/preferences-platform.test.ts new file mode 100644 index 000000000..ff356952d --- /dev/null +++ b/src/types/preferences-platform.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { setServerPlatform } from '@/lib/platform' +import { getTerminalOptions } from './preferences' + +describe('server platform option filtering', () => { + it('uses the Jean server platform for terminal options', () => { + setServerPlatform('linux') + expect(getTerminalOptions().map(option => option.value)).toEqual([ + 'terminal', + 'ghostty', + ]) + + setServerPlatform('windows') + expect(getTerminalOptions().map(option => option.value)).toContain( + 'powershell' + ) + }) +}) diff --git a/src/types/preferences.ts b/src/types/preferences.ts index f5fe45842..25de4cde8 100644 --- a/src/types/preferences.ts +++ b/src/types/preferences.ts @@ -1,6 +1,6 @@ import type { ThinkingLevel, EffortLevel, ExecutionMode } from './chat' import { DEFAULT_KEYBINDINGS, type KeybindingsMap } from './keybindings' -import { isMacOS, isWindows } from '../lib/platform' +import { getServerPlatform, isServerWindows } from '../lib/platform' export type CodexGoalExecutionMode = Extract @@ -1582,9 +1582,7 @@ export type TerminalApp = type Platform = 'mac' | 'windows' | 'linux' function getCurrentPlatform(): Platform { - if (isMacOS) return 'mac' - if (isWindows) return 'windows' - return 'linux' + return getServerPlatform() } const allTerminalOptions: { @@ -1604,8 +1602,14 @@ const allTerminalOptions: { }, ] +export function getTerminalOptions(): { value: TerminalApp; label: string }[] { + return allTerminalOptions.filter(opt => + opt.platforms.includes(getCurrentPlatform()) + ) +} + export const terminalOptions: { value: TerminalApp; label: string }[] = - allTerminalOptions.filter(opt => opt.platforms.includes(getCurrentPlatform())) + getTerminalOptions() export type EditorApp = 'zed' | 'vscode' | 'cursor' | 'xcode' | 'intellij' @@ -1633,8 +1637,14 @@ const allEditorOptions: { }, ] +export function getEditorOptions(): { value: EditorApp; label: string }[] { + return allEditorOptions.filter(opt => + opt.platforms.includes(getCurrentPlatform()) + ) +} + export const editorOptions: { value: EditorApp; label: string }[] = - allEditorOptions.filter(opt => opt.platforms.includes(getCurrentPlatform())) + getEditorOptions() export type OpenInDefault = 'editor' | 'terminal' | 'finder' | 'github' @@ -1879,7 +1889,7 @@ export const defaultPreferences: AppPreferences = { selected_model: 'claude-opus-4-8[1m]', thinking_level: 'ultrathink', default_effort_level: 'high', - terminal: isWindows ? 'powershell' : 'terminal', + terminal: isServerWindows() ? 'powershell' : 'terminal', terminal_renderer: 'xterm', terminal_font: 'jetbrains-mono', terminal_font_size: 13, From ea26e4f78ef812b407eb06fc584f18e9fb5fd1a5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:51:39 +0200 Subject: [PATCH 008/359] feat(headless): add single-binary server mode Embed frontend assets in the Rust server, add the jean-server entrypoint, serve health/readiness routes, and document VPS/Lima deployment. --- .gitignore | 1 + docs/headless-server.md | 155 +++++++++++++++ eslint.config.js | 1 + lima/jean-headless.yaml | 138 ++++++++++++++ src-tauri/Cargo.lock | 35 ++++ src-tauri/Cargo.toml | 1 + src-tauri/src/bin/jean-server.rs | 4 + src-tauri/src/http_server/assets.rs | 9 + src-tauri/src/http_server/mod.rs | 1 + src-tauri/src/http_server/server.rs | 279 ++++++++++++++++++++++----- src-tauri/src/lib.rs | 280 +++++++++++++++++++++++----- 11 files changed, 811 insertions(+), 93 deletions(-) create mode 100644 docs/headless-server.md create mode 100644 lima/jean-headless.yaml create mode 100644 src-tauri/src/bin/jean-server.rs create mode 100644 src-tauri/src/http_server/assets.rs diff --git a/.gitignore b/.gitignore index 45a622a27..4bf30cbed 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules package-lock.json dist/* !dist/.gitkeep +src-tauri/dist/ dist-ssr *.local *.local.* diff --git a/docs/headless-server.md b/docs/headless-server.md new file mode 100644 index 000000000..823a926f8 --- /dev/null +++ b/docs/headless-server.md @@ -0,0 +1,155 @@ +# Jean Headless Server + +Jean can run as a browser-accessible server without creating a Tauri WebView/window. This is intended first for Linux VPS, systemd, Docker, and Tailscale deployments. + +## Start locally + +When running a debug binary directly with `cargo build` / `./target/debug/jean`, +build the browser bundle first. Jean embeds `dist/` into the server binary at +compile time, so production deploys only need the compiled binary. + +```bash +bun run build +cd src-tauri +cargo build --bin jean --bin jean-server +``` + +```bash +unset DISPLAY WAYLAND_DISPLAY +./target/debug/jean --headless --host 127.0.0.1 --port 3456 +curl http://127.0.0.1:3456/healthz +``` + +You can also run the server entrypoint when packaged/available: + +```bash +jean-server --host 127.0.0.1 --port 3456 +``` + +For a production single-binary server: + +```bash +bun run build +cd src-tauri +cargo build --release --bin jean-server +./target/release/jean-server --host 0.0.0.0 --port 3456 --token "$JEAN_TOKEN" +``` + +After `cargo build --release --bin jean-server` finishes, `dist/` is no longer +needed on the target server. Re-run `bun run build` before compiling whenever +frontend code changes. + +## Options and environment + +| CLI | Environment | Default | +| --- | --- | --- | +| `--headless` | `JEAN_HEADLESS=1` | off | +| `--host ` | `JEAN_HOST` | saved preference, normally `127.0.0.1` | +| `--port ` | `JEAN_PORT` | `3456` | +| `--token ` | `JEAN_TOKEN` | saved/generated token | +| `--no-token` | `JEAN_NO_TOKEN=1` | token required | +| `--allow-unsafe-no-token` | `JEAN_ALLOW_UNSAFE_NO_TOKEN=1` | off | +| n/a | `JEAN_ALLOWED_ORIGINS` | same-origin only | + +`--token` and `--no-token` are mutually exclusive. Jean rejects `--no-token` with `--host 0.0.0.0` or `--host ::` unless `--allow-unsafe-no-token` is also set. + +## Health checks + +- `GET /healthz` — process is alive. +- `GET /readyz` — HTTP server is initialized and WebSocket broadcaster state is ready. + +Authenticated endpoints accept either the existing `?token=...` query parameter or an HTTP bearer token: + +```bash +curl -H "Authorization: Bearer $JEAN_TOKEN" http://127.0.0.1:3456/api/auth +curl "http://127.0.0.1:3456/api/init?token=$JEAN_TOKEN" +``` + +The browser UI still uses `/api/init`, `/api/auth`, and `/ws` from the same origin, so reverse proxies do not need to rewrite paths. + +## systemd example + +```ini +[Unit] +Description=Jean headless server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=jean +Environment=JEAN_HEADLESS=1 +Environment=JEAN_HOST=127.0.0.1 +Environment=JEAN_PORT=3456 +Environment=JEAN_TOKEN=change-me-long-random-token +ExecStart=/usr/local/bin/jean --headless +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +## Docker notes + +- Bind to `0.0.0.0` inside the container, but keep token auth enabled. +- Mount Jean's app-data directory as a volume so projects, preferences, and sessions persist. +- Put TLS/auth in front of the container for internet exposure. + +Example command: + +```bash +docker run --rm \ + -e JEAN_HEADLESS=1 \ + -e JEAN_HOST=0.0.0.0 \ + -e JEAN_PORT=3456 \ + -e JEAN_TOKEN=change-me-long-random-token \ + -p 127.0.0.1:3456:3456 \ + -v jean-data:/home/jean/.local/share/com.jean.desktop \ + jean:latest +``` + +## Reverse proxy + +### Caddy + +```caddyfile +jean.example.com { + encode zstd gzip + reverse_proxy 127.0.0.1:3456 +} +``` + +### Nginx + +```nginx +server { + listen 443 ssl http2; + server_name jean.example.com; + + location / { + proxy_pass http://127.0.0.1:3456; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## Tailscale binding + +Bind directly to the Tailscale IP and keep token auth enabled: + +```bash +jean --headless --host 100.x.y.z --port 3456 --token "$JEAN_TOKEN" +``` + +## Security recommendations + +- Prefer `127.0.0.1` behind Caddy/Nginx, SSH tunnel, or Tailscale. +- Keep token auth enabled for every non-localhost bind. +- Use a long random token, for example `openssl rand -base64 32`. +- Set `JEAN_ALLOWED_ORIGINS=https://jean.example.com` only when you need cross-origin browser access; otherwise keep the default same-origin behavior. diff --git a/eslint.config.js b/eslint.config.js index b4e3f584e..9ea9e13cb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -98,6 +98,7 @@ export default tseslint.config( { ignores: [ 'dist/**', + 'src-tauri/dist/**', 'node_modules/**', 'src-tauri/target/**', 'src-tauri/gen/**', diff --git a/lima/jean-headless.yaml b/lima/jean-headless.yaml new file mode 100644 index 000000000..9f80f005a --- /dev/null +++ b/lima/jean-headless.yaml @@ -0,0 +1,138 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/lima-vm/lima/master/schemas/lima.yaml.json +# Lima VM for testing Jean headless mode from the current local checkout. +# +# Usage: +# limactl start --name jean-headless ./lima/jean-headless.yaml +# limactl shell jean-headless sudo cat /etc/jean-headless.env +# open "http://127.0.0.1:3456/?token=" +# +# Reinstall the latest local source after host-side changes: +# limactl shell jean-headless /usr/local/bin/install-jean-local + +images: + - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img" + arch: "aarch64" + - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img" + arch: "x86_64" + +cpus: 4 +memory: "8GiB" +disk: "40GiB" + +mounts: + - location: "{{.Dir}}/.." + mountPoint: "/workspaces/jean" + writable: true + +portForwards: + - guestPort: 3456 + hostIP: "127.0.0.1" + hostPort: 3456 + +provision: + - mode: system + script: | + #!/usr/bin/env bash + set -euo pipefail + + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y \ + build-essential \ + ca-certificates \ + curl \ + git \ + libayatana-appindicator3-dev \ + libfuse2 \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + patchelf \ + pkg-config \ + xdg-utils + + cat >/usr/local/bin/install-jean-local <<'SCRIPT' + #!/usr/bin/env bash + set -euo pipefail + + export BUN_INSTALL="${HOME}/.bun" + export PATH="${BUN_INSTALL}/bin:${HOME}/.cargo/bin:${PATH}" + + if ! command -v bun >/dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash + fi + + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal + fi + + cd /workspaces/jean + bun install --frozen-lockfile + bun run build + + cd src-tauri + cargo build --bin jean-server --profile release-fast + + sudo install -m 0755 target/release-fast/jean-server /usr/local/bin/jean-server + sudo ln -sf /usr/local/bin/jean-server /usr/local/bin/jean + + if [ ! -f /etc/jean-headless.env ]; then + token="$(openssl rand -base64 32)" + sudo tee /etc/jean-headless.env >/dev/null </dev/null <<'UNIT' + [Unit] + Description=Jean headless server + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + EnvironmentFile=/etc/jean-headless.env + ExecStart=/usr/local/bin/jean-server + Restart=on-failure + RestartSec=5 + + [Install] + WantedBy=multi-user.target + UNIT + + sudo systemctl daemon-reload + sudo systemctl enable --now jean-headless.service + sudo systemctl restart jean-headless.service + SCRIPT + chmod 0755 /usr/local/bin/install-jean-local + + - mode: user + script: | + #!/usr/bin/env bash + set -euo pipefail + /usr/local/bin/install-jean-local + +probes: + - script: | + #!/usr/bin/env bash + set -euo pipefail + curl -fsS http://127.0.0.1:3456/healthz >/dev/null + hint: | + Jean headless did not become healthy yet. Check logs with: + limactl shell jean-headless journalctl -u jean-headless -n 100 --no-pager + +message: | + Jean headless is running in the VM and forwarded to localhost:3456. + + Get the token: + limactl shell {{.Name}} sudo sed -n 's/^JEAN_TOKEN=//p' /etc/jean-headless.env + + Open: + http://127.0.0.1:3456/?token= + + Reinstall latest local source: + limactl shell {{.Name}} /usr/local/bin/install-jean-local diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1afa379bb..9f162fc9f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2510,6 +2510,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest 0.12.28", + "rust-embed", "serde", "serde_json", "serde_yaml", @@ -4230,6 +4231,40 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust_decimal" version = "1.40.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3062dbfe8..24f3f6045 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -56,6 +56,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", arboard = { version = "3", features = ["wayland-data-control"] } # Native clipboard image read (Linux WebKitGTK fallback) if-addrs = "0.13" chrono = { version = "0.4", default-features = false, features = ["clock"] } # Local machine time for Auto Fix active-hours window +rust-embed = { version = "8", features = ["debug-embed"] } # Embed web assets for single-binary headless server [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/bin/jean-server.rs b/src-tauri/src/bin/jean-server.rs new file mode 100644 index 000000000..3496a4cfd --- /dev/null +++ b/src-tauri/src/bin/jean-server.rs @@ -0,0 +1,4 @@ +fn main() { + std::env::set_var("JEAN_HEADLESS", "1"); + jean_lib::run() +} diff --git a/src-tauri/src/http_server/assets.rs b/src-tauri/src/http_server/assets.rs new file mode 100644 index 000000000..453401f6e --- /dev/null +++ b/src-tauri/src/http_server/assets.rs @@ -0,0 +1,9 @@ +use std::borrow::Cow; + +#[derive(rust_embed::RustEmbed)] +#[folder = "../dist"] +pub struct FrontendAssets; + +pub fn get(path: &str) -> Option> { + FrontendAssets::get(path).map(|file| file.data) +} diff --git a/src-tauri/src/http_server/mod.rs b/src-tauri/src/http_server/mod.rs index 41a0bad46..ced8e1ef4 100644 --- a/src-tauri/src/http_server/mod.rs +++ b/src-tauri/src/http_server/mod.rs @@ -1,3 +1,4 @@ +pub mod assets; pub mod auth; pub mod dispatch; pub mod server; diff --git a/src-tauri/src/http_server/server.rs b/src-tauri/src/http_server/server.rs index 01e2709a0..d9c11d60c 100644 --- a/src-tauri/src/http_server/server.rs +++ b/src-tauri/src/http_server/server.rs @@ -1,7 +1,7 @@ use axum::{ body::Body, extract::{ws::WebSocketUpgrade, Path as AxumPath, Query, State}, - http::{header, StatusCode, Uri}, + http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, response::{IntoResponse, Response}, routing::get, Json, Router, @@ -15,8 +15,9 @@ use std::sync::Arc; use tauri::{AppHandle, Manager}; use tokio::sync::Mutex; use tower_http::compression::CompressionLayer; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::{AllowOrigin, Any, CorsLayer}; +use super::assets; use super::auth; use super::websocket::handle_ws_connection; use super::EmitExt; @@ -136,8 +137,10 @@ async fn read_web_build_info(dist_path: &std::path::Path) -> WebBuildInfo { WebBuildInfo::default() }), Err(e) => { - log::debug!("No web build info at {}: {e}", path.display()); - WebBuildInfo::default() + log::debug!("No filesystem web build info at {}: {e}", path.display()); + assets::get("jean-build.json") + .and_then(|data| serde_json::from_slice::(&data).ok()) + .unwrap_or_default() } } } @@ -223,12 +226,11 @@ pub async fn start_server( dist_path: dist_path.clone(), }; - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + let cors = cors_layer_from_env(); let router = Router::new() + .route("/healthz", get(health_handler)) + .route("/readyz", get(ready_handler)) .route("/ws", get(ws_handler)) .route("/api/auth", get(auth_handler)) .route("/api/init", get(init_handler)) @@ -279,18 +281,66 @@ pub async fn start_server( }) } +fn cors_layer_from_env() -> CorsLayer { + let mut layer = CorsLayer::new().allow_methods(Any).allow_headers(Any); + let raw = std::env::var("JEAN_ALLOWED_ORIGINS").unwrap_or_default(); + let origins: Vec = raw + .split(',') + .map(str::trim) + .filter(|origin| !origin.is_empty()) + .filter_map(|origin| match HeaderValue::from_str(origin) { + Ok(value) => Some(value), + Err(e) => { + log::warn!("Ignoring invalid JEAN_ALLOWED_ORIGINS entry '{origin}': {e}"); + None + } + }) + .collect(); + + if raw.trim() == "*" { + layer = layer.allow_origin(AllowOrigin::any()); + } else if !origins.is_empty() { + layer = layer.allow_origin(AllowOrigin::list(origins)); + } + + layer +} + +async fn health_handler() -> Response { + Json(serde_json::json!({ "ok": true })).into_response() +} + +async fn ready_handler(State(state): State) -> Response { + let broadcaster_ready = state.app.try_state::().is_some(); + let status = if broadcaster_ready { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + ( + status, + Json(serde_json::json!({ + "ok": broadcaster_ready, + "http": true, + "websocket_broadcaster": broadcaster_ready, + })), + ) + .into_response() +} + /// WebSocket upgrade handler with token auth. async fn ws_handler( ws: WebSocketUpgrade, + headers: HeaderMap, Query(params): Query, State(state): State, ) -> Response { // Validate token (skip if token not required) - if state.token_required { - let provided = params.token.as_deref().unwrap_or_default(); - if !auth::validate_token(provided, &state.token) { - return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); - } + if state.token_required + && !request_is_authorized(params.token.as_deref(), &headers, &state.token) + { + return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); } // Get broadcast receiver for this client @@ -308,7 +358,11 @@ async fn ws_handler( /// Token validation endpoint. Returns 200 with { ok: true } on success, /// or 401 with { ok: false, error: "..." } on failure. -async fn auth_handler(Query(params): Query, State(state): State) -> Response { +async fn auth_handler( + headers: HeaderMap, + Query(params): Query, + State(state): State, +) -> Response { let build_info = read_web_build_info(&state.dist_path).await; // If token not required, always return success @@ -322,8 +376,7 @@ async fn auth_handler(Query(params): Query, State(state): State, State(state): State, State(state): State) -> Response { - if state.token_required { - let provided = params.token.as_deref().unwrap_or_default(); - if !auth::validate_token(provided, &state.token) { - return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); - } +async fn version_handler( + headers: HeaderMap, + Query(params): Query, + State(state): State, +) -> Response { + if state.token_required + && !request_is_authorized(params.token.as_deref(), &headers, &state.token) + { + return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); } Json(read_web_build_info(&state.dist_path).await).into_response() @@ -583,13 +639,16 @@ async fn reconnect_init_response(params: WsAuth, state: AppState) -> Response { /// user lands on (project list + currently-selected project's worktrees + /// windowed messages for the focused session). Additional data is lazy-loaded /// by the frontend via TanStack Query hooks when the user navigates. -async fn init_handler(Query(params): Query, State(state): State) -> Response { +async fn init_handler( + headers: HeaderMap, + Query(params): Query, + State(state): State, +) -> Response { // Validate token (skip if token not required) - if state.token_required { - let provided = params.token.as_deref().unwrap_or_default(); - if !auth::validate_token(provided, &state.token) { - return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); - } + if state.token_required + && !request_is_authorized(params.token.as_deref(), &headers, &state.token) + { + return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); } if params.is_reconnect() { @@ -952,15 +1011,15 @@ fn mime_from_extension(path: &std::path::Path) -> &'static str { /// that Tauri's asset:// protocol would serve in native mode. async fn file_handler( AxumPath(filepath): AxumPath, + headers: HeaderMap, Query(params): Query, State(state): State, ) -> Response { // Validate token - if state.token_required { - let provided = params.token.unwrap_or_default(); - if !auth::validate_token(&provided, &state.token) { - return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); - } + if state.token_required + && !request_is_authorized(params.token.as_deref(), &headers, &state.token) + { + return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); } // Resolve app data directory @@ -1011,12 +1070,11 @@ async fn file_handler( } } -fn validate_token(params: &WsAuth, state: &AppState) -> Result<(), Response> { - if state.token_required { - let provided = params.token.clone().unwrap_or_default(); - if !auth::validate_token(&provided, &state.token) { - return Err((StatusCode::UNAUTHORIZED, "Invalid token").into_response()); - } +fn validate_token(params: &WsAuth, headers: &HeaderMap, state: &AppState) -> Result<(), Response> { + if state.token_required + && !request_is_authorized(params.token.as_deref(), headers, &state.token) + { + return Err((StatusCode::UNAUTHORIZED, "Invalid token").into_response()); } Ok(()) } @@ -1054,10 +1112,11 @@ fn path_is_in_known_roots(path: &std::path::Path, roots: &[std::path::PathBuf]) /// the native asset protocol's project directory allowlist. async fn project_file_handler( AxumPath(filepath): AxumPath, + headers: HeaderMap, Query(params): Query, State(state): State, ) -> Response { - if let Err(response) = validate_token(¶ms, &state) { + if let Err(response) = validate_token(¶ms, &headers, &state) { return response; } @@ -1117,11 +1176,22 @@ async fn static_handler(uri: Uri, State(state): State) -> Response { return (StatusCode::FORBIDDEN, "Access denied").into_response(); } - let index_path = state.dist_path.join("index.html"); + if let Some(response) = try_static_filesystem_response(raw_path, &state.dist_path).await { + return response; + } + + embedded_static_response(raw_path) +} + +async fn try_static_filesystem_response( + raw_path: &str, + dist_path: &std::path::Path, +) -> Option { + let index_path = dist_path.join("index.html"); let requested_path = if raw_path.is_empty() { index_path.clone() } else { - state.dist_path.join(raw_path) + dist_path.join(raw_path) }; let path = match tokio::fs::metadata(&requested_path).await { @@ -1130,21 +1200,21 @@ async fn static_handler(uri: Uri, State(state): State) -> Response { _ => index_path.clone(), }; - let canonical_base = match tokio::fs::canonicalize(&state.dist_path).await { + let canonical_base = match tokio::fs::canonicalize(dist_path).await { Ok(path) => path, - Err(_) => return (StatusCode::NOT_FOUND, "Frontend dist not found").into_response(), + Err(_) => return None, }; let canonical_path = match tokio::fs::canonicalize(&path).await { Ok(path) => path, - Err(_) => return (StatusCode::NOT_FOUND, "File not found").into_response(), + Err(_) => return None, }; if !canonical_path.starts_with(canonical_base) { - return (StatusCode::FORBIDDEN, "Access denied").into_response(); + return Some((StatusCode::FORBIDDEN, "Access denied").into_response()); } let bytes = match tokio::fs::read(&canonical_path).await { Ok(bytes) => bytes, - Err(_) => return (StatusCode::NOT_FOUND, "Cannot read file").into_response(), + Err(_) => return None, }; let canonical_index = index_path.canonicalize().unwrap_or(index_path); @@ -1155,13 +1225,53 @@ async fn static_handler(uri: Uri, State(state): State) -> Response { "public, max-age=31536000, immutable" }; + Some( + Response::builder() + .header( + header::CONTENT_TYPE, + static_mime_from_extension(&canonical_path), + ) + .header(header::CACHE_CONTROL, cache_control) + .body(Body::from(bytes)) + .unwrap() + .into_response(), + ) +} + +fn embedded_asset_path_for_request(raw_path: &str) -> &str { + if raw_path.is_empty() || !raw_path.contains('.') { + "index.html" + } else { + raw_path + } +} + +fn embedded_static_response(raw_path: &str) -> Response { + let asset_path = embedded_asset_path_for_request(raw_path); + let data = assets::get(asset_path).or_else(|| assets::get("index.html")); + + let Some(data) = data else { + return ( + StatusCode::NOT_FOUND, + "Frontend assets not found. Run `bun run build` before building jean-server.", + ) + .into_response(); + }; + + let is_index = asset_path == "index.html"; + let cache_control = if is_index || asset_path == "jean-build.json" { + "no-store" + } else { + "public, max-age=31536000, immutable" + }; + Response::builder() .header( header::CONTENT_TYPE, - static_mime_from_extension(&canonical_path), + static_mime_from_extension(std::path::Path::new(asset_path)), ) .header(header::CACHE_CONTROL, cache_control) - .body(Body::from(bytes)) + .body(Body::from(data.into_owned())) .unwrap() } @@ -1249,6 +1359,26 @@ fn format_http_url(host: &str, port: u16) -> String { } } +fn token_from_query_or_bearer(query_token: Option<&str>, headers: &HeaderMap) -> Option { + if let Some(token) = query_token.filter(|token| !token.is_empty()) { + return Some(token.to_string()); + } + + let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?.trim(); + value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer ")) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(ToOwned::to_owned) +} + +fn request_is_authorized(query_token: Option<&str>, headers: &HeaderMap, expected: &str) -> bool { + token_from_query_or_bearer(query_token, headers) + .as_deref() + .is_some_and(|provided| auth::validate_token(provided, expected)) +} + pub fn list_bind_host_options() -> Vec { let mut seen = HashSet::from([ "127.0.0.1".to_string(), @@ -1367,9 +1497,11 @@ pub async fn get_server_status(app: AppHandle) -> ServerStatus { mod tests { use super::{ bind_host_option_label, bind_host_option_rank, display_host_for_bind_ip, - display_ip_for_bind_ip_with_candidates, format_http_url, is_tailscale_ipv4, parse_bind_ip, - path_is_in_known_roots, validate_bind_host, + display_ip_for_bind_ip_with_candidates, embedded_asset_path_for_request, format_http_url, + is_tailscale_ipv4, parse_bind_ip, path_is_in_known_roots, token_from_query_or_bearer, + validate_bind_host, }; + use axum::http::{HeaderMap, HeaderValue}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; #[test] @@ -1388,6 +1520,34 @@ mod tests { ); } + #[test] + fn token_auth_accepts_bearer_authorization_header() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer secret-token"), + ); + + assert_eq!( + token_from_query_or_bearer(None, &headers), + Some("secret-token".to_string()) + ); + } + + #[test] + fn token_auth_prefers_query_token_for_browser_compatibility() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer header-token"), + ); + + assert_eq!( + token_from_query_or_bearer(Some("query-token"), &headers), + Some("query-token".to_string()) + ); + } + #[test] fn parse_bind_ip_rejects_invalid_values() { let error = parse_bind_ip("tailscale").unwrap_err(); @@ -1491,6 +1651,23 @@ mod tests { assert_eq!(format_http_url("::1", 3456), "http://[::1]:3456"); } + #[test] + fn embedded_asset_path_maps_root_and_spa_routes_to_index() { + assert_eq!(embedded_asset_path_for_request(""), "index.html"); + assert_eq!( + embedded_asset_path_for_request("projects/abc"), + "index.html" + ); + } + + #[test] + fn embedded_asset_path_keeps_asset_paths() { + assert_eq!( + embedded_asset_path_for_request("assets/app.js"), + "assets/app.js" + ); + } + #[test] fn wildcard_display_urls_never_use_unspecified_hosts() { let ipv6_url = format_http_url( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 905b5764d..52edc94d9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -698,7 +698,11 @@ fn resolve_http_server_bind_host(prefs: &AppPreferences) -> String { #[cfg(test)] mod tests { - use super::{default_global_system_prompt, resolve_http_server_bind_host, AppPreferences}; + use super::{ + default_global_system_prompt, parse_cli_args_from, resolve_headless_bind_host, + resolve_headless_token_required, resolve_http_server_bind_host, validate_headless_security, + AppPreferences, + }; use serde_json::json; #[test] @@ -745,6 +749,97 @@ mod tests { assert_eq!(resolve_http_server_bind_host(&prefs), "0.0.0.0"); } + #[test] + fn parse_cli_args_reads_headless_env_defaults() { + let env = [ + ("JEAN_HEADLESS", "1"), + ("JEAN_HOST", "127.0.0.1"), + ("JEAN_PORT", "4567"), + ("JEAN_TOKEN", "secret"), + ]; + + let args = parse_cli_args_from(["jean"], env).unwrap(); + + assert!(args.headless); + assert_eq!(args.host.as_deref(), Some("127.0.0.1")); + assert_eq!(args.port, Some(4567)); + assert_eq!(args.token.as_deref(), Some("secret")); + assert!(!args.no_token); + } + + #[test] + fn cli_args_override_env_defaults() { + let env = [ + ("JEAN_HEADLESS", "1"), + ("JEAN_HOST", "127.0.0.1"), + ("JEAN_PORT", "4567"), + ("JEAN_TOKEN", "secret"), + ]; + + let args = parse_cli_args_from( + [ + "jean", + "--host", + "100.64.0.1", + "--port", + "5678", + "--token", + "cli-secret", + ], + env, + ) + .unwrap(); + + assert_eq!(args.host.as_deref(), Some("100.64.0.1")); + assert_eq!(args.port, Some(5678)); + assert_eq!(args.token.as_deref(), Some("cli-secret")); + } + + #[test] + fn no_token_and_token_are_mutually_exclusive_across_env_and_cli() { + let err = parse_cli_args_from(["jean", "--token", "secret"], [("JEAN_NO_TOKEN", "1")]) + .unwrap_err(); + + assert!(err.contains("mutually exclusive")); + } + + #[test] + fn headless_defaults_to_localhost_when_no_host_is_configured() { + let prefs = AppPreferences::default(); + let host = resolve_headless_bind_host(&prefs, &None); + + assert_eq!(host, "127.0.0.1"); + } + + #[test] + fn headless_rejects_no_token_on_wildcard_host_without_unsafe_flag() { + let err = validate_headless_security("0.0.0.0", true, false).unwrap_err(); + + assert!(err.contains("Refusing to disable token authentication")); + } + + #[test] + fn headless_allows_no_token_on_wildcard_host_with_unsafe_flag() { + assert!(validate_headless_security("0.0.0.0", true, true).is_ok()); + } + + #[test] + fn explicit_headless_token_requires_auth_even_when_preference_disabled() { + let prefs = AppPreferences { + http_server_token_required: false, + ..Default::default() + }; + let overrides = super::HttpServerOverrides { + host: None, + port: None, + token: Some("secret".to_string()), + no_token: false, + allow_unsafe_no_token: false, + }; + + assert!(resolve_headless_token_required(&prefs, &overrides)); + } + #[test] fn migrate_default_claude_model_keeps_standard_non_1m_models() { assert_eq!(super::migrate_default_claude_model("claude-opus-4-8"), None); @@ -2949,7 +3044,6 @@ async fn stop_http_server(app: AppHandle) -> Result<(), String> { async fn start_http_server_headless( app: AppHandle, default_port: u16, - bind_all_interfaces: bool, overrides: &HttpServerOverrides, ) -> Result { use std::sync::Arc; @@ -2960,21 +3054,16 @@ async fn start_http_server_headless( // Port: CLI override > preference let port = overrides.port.unwrap_or(default_port); - // Host: CLI --host overrides bind_all_interfaces and preference - let bind_host = if let Some(ref host) = overrides.host { - host.clone() - } else if bind_all_interfaces { - "0.0.0.0".to_string() - } else { - resolve_http_server_bind_host(&prefs) - }; + // Host: CLI/env override > saved preference. + let bind_host = resolve_headless_bind_host(&prefs, &overrides.host); - // Token required: --no-token overrides preference - let token_required = if overrides.no_token { - false - } else { - prefs.http_server_token_required - }; + let token_required = resolve_headless_token_required(&prefs, overrides); + + validate_headless_security( + &bind_host, + overrides.no_token, + overrides.allow_unsafe_no_token, + )?; // Token: CLI --token used directly (not persisted), otherwise load/generate let token = if let Some(ref t) = overrides.token { @@ -3383,12 +3472,14 @@ pub fn fix_macos_path() { } /// Parsed CLI arguments for headless server mode. +#[derive(Debug)] struct CliArgs { headless: bool, host: Option, port: Option, token: Option, no_token: bool, + allow_unsafe_no_token: bool, } /// CLI overrides for HTTP server configuration. @@ -3398,6 +3489,7 @@ struct HttpServerOverrides { port: Option, token: Option, no_token: bool, + allow_unsafe_no_token: bool, } fn print_cli_help() { @@ -3408,14 +3500,18 @@ fn print_cli_help() { println!(); println!("Options:"); println!(" --headless Run without GUI (HTTP server only)"); - println!( - " --host Bind to an IP address or localhost (default: 0.0.0.0 in headless)" - ); + println!(" --host Bind to an IP address or localhost (default: 127.0.0.1)"); println!(" --port HTTP server port (overrides saved preference)"); println!(" --token Use specific auth token (not persisted)"); println!(" --no-token Disable token authentication"); + println!(" --allow-unsafe-no-token"); + println!(" Allow --no-token with a wildcard bind host"); println!(" --help Show this help message"); println!(" --version Show version"); + println!(); + println!("Environment:"); + println!(" JEAN_HEADLESS=1 JEAN_HOST JEAN_PORT JEAN_TOKEN JEAN_NO_TOKEN=1"); + println!(" JEAN_ALLOW_UNSAFE_NO_TOKEN=1"); } fn parse_cli_args() -> CliArgs { @@ -3430,51 +3526,107 @@ fn parse_cli_args() -> CliArgs { std::process::exit(0); } - let headless = args.iter().any(|a| a == "--headless"); - let no_token = args.iter().any(|a| a == "--no-token"); + match parse_cli_args_from(args, std::env::vars()) { + Ok(parsed) => parsed, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } +} + +fn env_truthy(value: Option<&str>) -> bool { + matches!( + value.map(|v| v.trim().to_ascii_lowercase()), + Some(v) if matches!(v.as_str(), "1" | "true" | "yes" | "on") + ) +} - let mut host = None; - let mut port = None; - let mut token = None; +fn parse_cli_args_from(args: A, env: E) -> Result +where + A: IntoIterator, + A::Item: AsRef, + E: IntoIterator, + K: AsRef, + V: AsRef, +{ + let args: Vec = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect(); + let env: std::collections::HashMap = env + .into_iter() + .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string())) + .collect(); + + let mut headless = env_truthy(env.get("JEAN_HEADLESS").map(String::as_str)); + let mut no_token = env_truthy(env.get("JEAN_NO_TOKEN").map(String::as_str)); + let mut allow_unsafe_no_token = + env_truthy(env.get("JEAN_ALLOW_UNSAFE_NO_TOKEN").map(String::as_str)); + let mut host = env + .get("JEAN_HOST") + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()); + let mut port = match env + .get("JEAN_PORT") + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + { + Some(value) => Some( + value + .parse::() + .map_err(|_| "JEAN_PORT must be a valid port number (1-65535)".to_string())?, + ), + None => None, + }; + let mut token = env + .get("JEAN_TOKEN") + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); let mut iter = args.iter().skip(1); while let Some(arg) = iter.next() { match arg.as_str() { + "--headless" => { + headless = true; + } "--host" => { host = iter.next().cloned(); - if host.is_none() { - eprintln!("Error: --host requires an address argument"); - std::process::exit(1); - } + host.as_ref() + .filter(|h| !h.trim().is_empty()) + .ok_or_else(|| "--host requires an address argument".to_string())?; } "--port" => { if let Some(val) = iter.next() { match val.parse::() { Ok(p) => port = Some(p), Err(_) => { - eprintln!("Error: --port requires a valid port number (1-65535)"); - std::process::exit(1); + return Err("--port requires a valid port number (1-65535)".to_string()); } } } else { - eprintln!("Error: --port requires a port number argument"); - std::process::exit(1); + return Err("--port requires a port number argument".to_string()); } } "--token" => { token = iter.next().cloned(); - if token.is_none() { - eprintln!("Error: --token requires a token argument"); - std::process::exit(1); - } + token + .as_ref() + .filter(|t| !t.trim().is_empty()) + .ok_or_else(|| "--token requires a token argument".to_string())?; + } + "--no-token" => { + no_token = true; + } + "--allow-unsafe-no-token" => { + allow_unsafe_no_token = true; } _ => {} // ignore unknown flags (Tauri/OS may pass their own) } } if token.is_some() && no_token { - eprintln!("Error: --token and --no-token are mutually exclusive"); - std::process::exit(1); + return Err("--token and --no-token are mutually exclusive".to_string()); } if !headless && (host.is_some() || port.is_some() || token.is_some() || no_token) { @@ -3483,12 +3635,50 @@ fn parse_cli_args() -> CliArgs { ); } - CliArgs { + Ok(CliArgs { headless, host, port, token, no_token, + allow_unsafe_no_token, + }) +} + +fn resolve_headless_bind_host(prefs: &AppPreferences, override_host: &Option) -> String { + override_host + .as_deref() + .and_then(|host| normalize_http_bind_host(Some(host))) + .unwrap_or_else(|| resolve_http_server_bind_host(prefs)) +} + +fn is_wildcard_bind_host(host: &str) -> bool { + matches!(host.trim(), "0.0.0.0" | "::") +} + +fn validate_headless_security( + bind_host: &str, + no_token: bool, + allow_unsafe_no_token: bool, +) -> Result<(), String> { + if no_token && is_wildcard_bind_host(bind_host) && !allow_unsafe_no_token { + return Err( + "Refusing to disable token authentication while binding to all interfaces. Use a token, bind to 127.0.0.1, or pass --allow-unsafe-no-token.".to_string(), + ); + } + Ok(()) +} + +fn resolve_headless_token_required( + prefs: &AppPreferences, + overrides: &HttpServerOverrides, +) -> bool { + if overrides.no_token { + false + } else if overrides.token.is_some() { + true + } else { + prefs.http_server_token_required } } @@ -3607,7 +3797,7 @@ pub fn run() { // - JEAN_FORCE_X11=1 to force X11 backend in non-AppImage runs (default: no) // - WEBKIT_DISABLE_COMPOSITING_MODE=0 to re-enable GPU compositing (risky) #[cfg(target_os = "linux")] - { + if !headless { log::trace!("Setting WebKit compatibility fixes for Linux"); // Detect if running inside an AppImage @@ -4142,6 +4332,7 @@ pub fn run() { port: cli_args.port, token: cli_args.token, no_token: cli_args.no_token, + allow_unsafe_no_token: cli_args.allow_unsafe_no_token, }; tauri::async_runtime::spawn(async move { match load_preferences(app_handle_http.clone()).await { @@ -4151,7 +4342,6 @@ pub fn run() { match start_http_server_headless( app_handle_http, port, - headless, // In headless mode, bind to 0.0.0.0 &server_overrides, ) .await @@ -4631,7 +4821,13 @@ pub fn run() { opencode_server::stop_opencode_server, opencode_server::get_opencode_server_status, ]) - .build(tauri::generate_context!()) + .build({ + let mut context = tauri::generate_context!(); + if headless { + context.config_mut().app.windows.clear(); + } + context + }) .expect("error building tauri application") .run(move |app_handle, event| match &event { tauri::RunEvent::Exit => { From 57e7fc4ca49ef054eed0949a1e4c6a5306e94f29 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:10:39 +0200 Subject: [PATCH 009/359] fix(headless): reject disabled tokens on wildcard hosts Reject wildcard headless binds when token auth is disabled via saved preferences, not only when --no-token is passed. Update headless docs for jean-server and remove the Lima VM config. --- docs/headless-server.md | 7 +- lima/jean-headless.yaml | 138 ---------------------------------------- src-tauri/src/lib.rs | 29 ++++++++- 3 files changed, 29 insertions(+), 145 deletions(-) delete mode 100644 lima/jean-headless.yaml diff --git a/docs/headless-server.md b/docs/headless-server.md index 823a926f8..ef3dc548a 100644 --- a/docs/headless-server.md +++ b/docs/headless-server.md @@ -47,11 +47,11 @@ frontend code changes. | `--host ` | `JEAN_HOST` | saved preference, normally `127.0.0.1` | | `--port ` | `JEAN_PORT` | `3456` | | `--token ` | `JEAN_TOKEN` | saved/generated token | -| `--no-token` | `JEAN_NO_TOKEN=1` | token required | +| `--no-token` | `JEAN_NO_TOKEN=1` | off | | `--allow-unsafe-no-token` | `JEAN_ALLOW_UNSAFE_NO_TOKEN=1` | off | | n/a | `JEAN_ALLOWED_ORIGINS` | same-origin only | -`--token` and `--no-token` are mutually exclusive. Jean rejects `--no-token` with `--host 0.0.0.0` or `--host ::` unless `--allow-unsafe-no-token` is also set. +By default a token is required (using `--token`, `JEAN_TOKEN`, or an auto-generated one); pass `--no-token` to disable it. `--token` and `--no-token` are mutually exclusive. Jean rejects `--no-token` with `--host 0.0.0.0` or `--host ::` unless `--allow-unsafe-no-token` is also set. ## Health checks @@ -78,11 +78,10 @@ Wants=network-online.target [Service] Type=simple User=jean -Environment=JEAN_HEADLESS=1 Environment=JEAN_HOST=127.0.0.1 Environment=JEAN_PORT=3456 Environment=JEAN_TOKEN=change-me-long-random-token -ExecStart=/usr/local/bin/jean --headless +ExecStart=/usr/local/bin/jean-server Restart=on-failure RestartSec=5 diff --git a/lima/jean-headless.yaml b/lima/jean-headless.yaml deleted file mode 100644 index 9f80f005a..000000000 --- a/lima/jean-headless.yaml +++ /dev/null @@ -1,138 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/lima-vm/lima/master/schemas/lima.yaml.json -# Lima VM for testing Jean headless mode from the current local checkout. -# -# Usage: -# limactl start --name jean-headless ./lima/jean-headless.yaml -# limactl shell jean-headless sudo cat /etc/jean-headless.env -# open "http://127.0.0.1:3456/?token=" -# -# Reinstall the latest local source after host-side changes: -# limactl shell jean-headless /usr/local/bin/install-jean-local - -images: - - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img" - arch: "aarch64" - - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img" - arch: "x86_64" - -cpus: 4 -memory: "8GiB" -disk: "40GiB" - -mounts: - - location: "{{.Dir}}/.." - mountPoint: "/workspaces/jean" - writable: true - -portForwards: - - guestPort: 3456 - hostIP: "127.0.0.1" - hostPort: 3456 - -provision: - - mode: system - script: | - #!/usr/bin/env bash - set -euo pipefail - - export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get install -y \ - build-essential \ - ca-certificates \ - curl \ - git \ - libayatana-appindicator3-dev \ - libfuse2 \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - patchelf \ - pkg-config \ - xdg-utils - - cat >/usr/local/bin/install-jean-local <<'SCRIPT' - #!/usr/bin/env bash - set -euo pipefail - - export BUN_INSTALL="${HOME}/.bun" - export PATH="${BUN_INSTALL}/bin:${HOME}/.cargo/bin:${PATH}" - - if ! command -v bun >/dev/null 2>&1; then - curl -fsSL https://bun.sh/install | bash - fi - - if ! command -v cargo >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal - fi - - cd /workspaces/jean - bun install --frozen-lockfile - bun run build - - cd src-tauri - cargo build --bin jean-server --profile release-fast - - sudo install -m 0755 target/release-fast/jean-server /usr/local/bin/jean-server - sudo ln -sf /usr/local/bin/jean-server /usr/local/bin/jean - - if [ ! -f /etc/jean-headless.env ]; then - token="$(openssl rand -base64 32)" - sudo tee /etc/jean-headless.env >/dev/null </dev/null <<'UNIT' - [Unit] - Description=Jean headless server - After=network-online.target - Wants=network-online.target - - [Service] - Type=simple - EnvironmentFile=/etc/jean-headless.env - ExecStart=/usr/local/bin/jean-server - Restart=on-failure - RestartSec=5 - - [Install] - WantedBy=multi-user.target - UNIT - - sudo systemctl daemon-reload - sudo systemctl enable --now jean-headless.service - sudo systemctl restart jean-headless.service - SCRIPT - chmod 0755 /usr/local/bin/install-jean-local - - - mode: user - script: | - #!/usr/bin/env bash - set -euo pipefail - /usr/local/bin/install-jean-local - -probes: - - script: | - #!/usr/bin/env bash - set -euo pipefail - curl -fsS http://127.0.0.1:3456/healthz >/dev/null - hint: | - Jean headless did not become healthy yet. Check logs with: - limactl shell jean-headless journalctl -u jean-headless -n 100 --no-pager - -message: | - Jean headless is running in the VM and forwarded to localhost:3456. - - Get the token: - limactl shell {{.Name}} sudo sed -n 's/^JEAN_TOKEN=//p' /etc/jean-headless.env - - Open: - http://127.0.0.1:3456/?token= - - Reinstall latest local source: - limactl shell {{.Name}} /usr/local/bin/install-jean-local diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 52edc94d9..1a856db4d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -840,6 +840,29 @@ mod tests { assert!(resolve_headless_token_required(&prefs, &overrides)); } + #[test] + fn headless_rejects_disabled_token_preference_on_wildcard_host() { + let prefs = AppPreferences { + http_server_token_required: false, + ..Default::default() + }; + let overrides = super::HttpServerOverrides { + host: Some("0.0.0.0".to_string()), + port: None, + token: None, + no_token: false, + allow_unsafe_no_token: false, + }; + + let bind_host = resolve_headless_bind_host(&prefs, &overrides.host); + let token_required = resolve_headless_token_required(&prefs, &overrides); + let err = + validate_headless_security(&bind_host, !token_required, overrides.allow_unsafe_no_token) + .unwrap_err(); + + assert!(err.contains("Refusing to disable token authentication")); + } + #[test] fn migrate_default_claude_model_keeps_standard_non_1m_models() { assert_eq!(super::migrate_default_claude_model("claude-opus-4-8"), None); @@ -3061,7 +3084,7 @@ async fn start_http_server_headless( validate_headless_security( &bind_host, - overrides.no_token, + !token_required, overrides.allow_unsafe_no_token, )?; @@ -3658,10 +3681,10 @@ fn is_wildcard_bind_host(host: &str) -> bool { fn validate_headless_security( bind_host: &str, - no_token: bool, + token_auth_disabled: bool, allow_unsafe_no_token: bool, ) -> Result<(), String> { - if no_token && is_wildcard_bind_host(bind_host) && !allow_unsafe_no_token { + if token_auth_disabled && is_wildcard_bind_host(bind_host) && !allow_unsafe_no_token { return Err( "Refusing to disable token authentication while binding to all interfaces. Use a token, bind to 127.0.0.1, or pass --allow-unsafe-no-token.".to_string(), ); From 4c41c9f584308913701da2e6b503cff3f5316a1d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:26:04 +0200 Subject: [PATCH 010/359] chore(cargo): set default binary to jean --- src-tauri/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 24f3f6045..9c39ef896 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.60" description = "Jean - AI Assistant" authors = ["Andras Bacsai"] edition = "2021" +default-run = "jean" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From 4afe90fd986e6ec1d04a21f3c101fe179eb44eab Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:22:01 +0200 Subject: [PATCH 011/359] ci(server): publish headless server artifacts Add a release workflow for Linux server binaries and GHCR Docker images. Build the Docker image with Xvfb so jean-server can run headlessly. --- .dockerignore | 10 ++ .github/workflows/server-release.yml | 158 +++++++++++++++++++++++++++ Dockerfile.server | 46 ++++++++ docs/headless-server.md | 19 +++- package.json | 3 +- scripts/docker-entrypoint.sh | 10 ++ scripts/server-ci-assets.test.mjs | 38 +++++++ 7 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/server-release.yml create mode 100644 Dockerfile.server create mode 100755 scripts/docker-entrypoint.sh create mode 100644 scripts/server-ci-assets.test.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..96dc28b36 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +node_modules +src-tauri/target +dist +.DS_Store +*.log +.env +screenshots +tmp diff --git a/.github/workflows/server-release.yml b/.github/workflows/server-release.yml new file mode 100644 index 000000000..0ff854b53 --- /dev/null +++ b/.github/workflows/server-release.yml @@ -0,0 +1,158 @@ +name: Server Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: 'Release version/tag for manual runs (for example v0.1.60)' + required: false + type: string + +permissions: + contents: write + packages: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}-server + +jobs: + metadata: + name: Resolve version + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Resolve release version + id: version + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.version }} + run: | + if [ -z "$RELEASE_TAG" ]; then + RELEASE_TAG="manual-${GITHUB_SHA::7}" + fi + + VERSION="${RELEASE_TAG#v}" + echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building Jean server artifacts for $RELEASE_TAG" + + build-binaries: + name: Build server binary (${{ matrix.arch }}) + needs: metadata + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + arch: amd64 + asset: jean-server-linux-amd64 + - runner: ubuntu-22.04-arm + arch: arm64 + asset: jean-server-linux-arm64 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + shared-key: server-${{ matrix.arch }} + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + + - name: Build frontend bundle + run: bun run build + + - name: Build jean-server + run: cd src-tauri && cargo build --release --bin jean-server + + - name: Package binary + env: + VERSION: ${{ needs.metadata.outputs.version }} + ASSET: ${{ matrix.asset }} + run: | + mkdir -p artifacts + cp src-tauri/target/release/jean-server "artifacts/${ASSET}" + tar -C artifacts -czf "artifacts/${ASSET}-${VERSION}.tar.gz" "${ASSET}" + shasum -a 256 "artifacts/${ASSET}-${VERSION}.tar.gz" > "artifacts/${ASSET}-${VERSION}.tar.gz.sha256" + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: jean-server-linux-${{ matrix.arch }} + path: | + artifacts/*.tar.gz + artifacts/*.sha256 + + - name: Upload release assets + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.metadata.outputs.tag }} + run: | + gh release upload "$RELEASE_TAG" artifacts/*.tar.gz artifacts/*.sha256 --clobber + + docker: + name: Build and publish Docker image + needs: metadata + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.event_name == 'release' }} + type=raw,value=${{ needs.metadata.outputs.tag }} + type=sha,prefix=sha- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.server + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 000000000..577d33caf --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,46 @@ +FROM oven/bun:1.2.22 AS frontend +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY . . +RUN bun run build + +FROM rust:1-bookworm AS builder +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + xdg-utils \ + && rm -rf /var/lib/apt/lists/* +COPY src-tauri ./src-tauri +COPY --from=frontend /app/dist ./dist +RUN cd src-tauri && RUSTC_WRAPPER= cargo build --release --bin jean-server + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + openssh-client \ + libwebkit2gtk-4.1-0 \ + libayatana-appindicator3-1 \ + librsvg2-2 \ + xdg-utils \ + xvfb \ + xauth \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --shell /bin/bash jean \ + && mkdir -p /home/jean/.cache /home/jean/.config /home/jean/.local/share/com.jean.desktop /tmp/.X11-unix \ + && chown -R jean:jean /home/jean \ + && chmod 1777 /tmp/.X11-unix +COPY --from=builder /app/src-tauri/target/release/jean-server /usr/local/bin/jean-server +COPY scripts/docker-entrypoint.sh /usr/local/bin/jean-server-entrypoint +RUN chmod +x /usr/local/bin/jean-server-entrypoint +USER jean +ENV JEAN_HOST=0.0.0.0 \ + JEAN_PORT=3456 +EXPOSE 3456 +VOLUME ["/home/jean/.local/share/com.jean.desktop"] +ENTRYPOINT ["/usr/local/bin/jean-server-entrypoint"] diff --git a/docs/headless-server.md b/docs/headless-server.md index ef3dc548a..c8e704db0 100644 --- a/docs/headless-server.md +++ b/docs/headless-server.md @@ -1,6 +1,11 @@ # Jean Headless Server -Jean can run as a browser-accessible server without creating a Tauri WebView/window. This is intended first for Linux VPS, systemd, Docker, and Tailscale deployments. +Jean can run as a browser-accessible server without creating a visible Tauri WebView/window. This is intended first for Linux VPS, systemd, Docker, and Tailscale deployments. + +On Linux, the Tauri/GTK runtime still needs a display backend to initialize even +when Jean is headless. The Docker image starts `Xvfb` automatically. For a raw +Linux binary on a server without `DISPLAY`, run it under `xvfb-run` or provide an +X/Wayland display. ## Start locally @@ -15,8 +20,7 @@ cargo build --bin jean --bin jean-server ``` ```bash -unset DISPLAY WAYLAND_DISPLAY -./target/debug/jean --headless --host 127.0.0.1 --port 3456 +xvfb-run -a ./target/debug/jean --headless --host 127.0.0.1 --port 3456 curl http://127.0.0.1:3456/healthz ``` @@ -32,7 +36,7 @@ For a production single-binary server: bun run build cd src-tauri cargo build --release --bin jean-server -./target/release/jean-server --host 0.0.0.0 --port 3456 --token "$JEAN_TOKEN" +xvfb-run -a ./target/release/jean-server --host 0.0.0.0 --port 3456 --token "$JEAN_TOKEN" ``` After `cargo build --release --bin jean-server` finishes, `dist/` is no longer @@ -81,7 +85,7 @@ User=jean Environment=JEAN_HOST=127.0.0.1 Environment=JEAN_PORT=3456 Environment=JEAN_TOKEN=change-me-long-random-token -ExecStart=/usr/local/bin/jean-server +ExecStart=/usr/bin/xvfb-run -a /usr/local/bin/jean-server Restart=on-failure RestartSec=5 @@ -91,6 +95,9 @@ WantedBy=multi-user.target ## Docker notes +- The server Docker image is published by the Server Release workflow as + `ghcr.io//-server:`. +- The image starts `Xvfb` internally before launching `jean-server`. - Bind to `0.0.0.0` inside the container, but keep token auth enabled. - Mount Jean's app-data directory as a volume so projects, preferences, and sessions persist. - Put TLS/auth in front of the container for internet exposure. @@ -105,7 +112,7 @@ docker run --rm \ -e JEAN_TOKEN=change-me-long-random-token \ -p 127.0.0.1:3456:3456 \ -v jean-data:/home/jean/.local/share/com.jean.desktop \ - jean:latest + ghcr.io/OWNER/REPO-server:latest ``` ## Reverse proxy diff --git a/package.json b/package.json index 4eaaa65ef..2fdbf7a9c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "task:complete": "node scripts/complete-task.js", "task:rename-done": "node scripts/complete-task.js --rename-existing", "version:bump": "node scripts/bump-version.js", - "release:assert-version": "node scripts/assert-release-version.js" + "release:assert-version": "node scripts/assert-release-version.js", + "test:server-ci": "node --test scripts/server-ci-assets.test.mjs" }, "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 000000000..ea1fe9727 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +if [ -z "${DISPLAY:-}" ]; then + Xvfb :99 -screen 0 1280x1024x24 -nolisten tcp & + export DISPLAY=:99 + sleep 0.5 +fi + +exec jean-server "$@" diff --git a/scripts/server-ci-assets.test.mjs b/scripts/server-ci-assets.test.mjs new file mode 100644 index 000000000..f90697612 --- /dev/null +++ b/scripts/server-ci-assets.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = path => readFileSync(path, 'utf8') + +test('server release workflow builds binaries and publishes docker image', () => { + const workflow = read('.github/workflows/server-release.yml') + + assert.match(workflow, /cargo build --release --bin jean-server/) + assert.match(workflow, /jean-server-linux-amd64/) + assert.match(workflow, /jean-server-linux-arm64/) + assert.match(workflow, /docker\/build-push-action@v6/) + assert.match(workflow, /ghcr\.io/) +}) + +test('Dockerfile builds and runs jean-server headlessly as non-root user', () => { + const dockerfile = read('Dockerfile.server') + + assert.match(dockerfile, /bun run build/) + assert.match(dockerfile, /cargo build --release --bin jean-server/) + assert.match(dockerfile, /USER jean/) + assert.match(dockerfile, /chown -R jean:jean \/home\/jean/) + assert.match(dockerfile, /JEAN_HOST=0\.0\.0\.0/) + assert.match(dockerfile, /xvfb/) + assert.match(dockerfile, /jean-server-entrypoint/) + assert.match(dockerfile, /ENTRYPOINT \["\/usr\/local\/bin\/jean-server-entrypoint"\]/) +}) + + +test('Docker entrypoint starts Xvfb before jean-server', () => { + const entrypoint = read('scripts/docker-entrypoint.sh') + + assert.match(entrypoint, /Xvfb :99/) + assert.match(entrypoint, /export DISPLAY=:99/) + assert.match(entrypoint, /sleep 0\.5/) + assert.match(entrypoint, /exec jean-server "\$@"/) +}) From 014a24e6d566eef4778c880960c2929a204572fc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:22:40 +0200 Subject: [PATCH 012/359] chore(release): bump version to 0.1.61 --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 2fdbf7a9c..1a33d37e0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "jean", "private": true, - "version": "0.1.60", + "version": "0.1.61", "type": "module", "author": "Andras Bacsai", "copyright": "Copyright © 2025 Andras Bacsai. All rights reserved.", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9c39ef896..2342c0a6c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jean" -version = "0.1.60" +version = "0.1.61" description = "Jean - AI Assistant" authors = ["Andras Bacsai"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e17a44615..01d694a01 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Jean", - "version": "0.1.60", + "version": "0.1.61", "identifier": "com.jean.desktop", "build": { "removeUnusedCommands": true, From 309ed13a82e277aaff8b1fcd14301929be616919 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:29:09 +0200 Subject: [PATCH 013/359] fix(wsl): find tools in common home bin paths --- src-tauri/src/platform/wsl.rs | 52 +++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/platform/wsl.rs b/src-tauri/src/platform/wsl.rs index 64298d5bd..5d9a5aad3 100644 --- a/src-tauri/src/platform/wsl.rs +++ b/src-tauri/src/platform/wsl.rs @@ -346,25 +346,39 @@ fn select_wsl_which_candidate(output: &str, jean_managed: Option<&str>) -> Optio .map(ToString::to_string) } -/// Resolve the Unix path of a tool inside a WSL distro via `type -P -a` -/// in a login shell, optionally excluding Jean's managed binary. -#[cfg(windows)] -pub fn wsl_which(distro: &str, tool: &str, jean_managed: Option<&str>) -> Option { - let script = if let Some(jean_path) = jean_managed.map(str::trim).filter(|p| !p.is_empty()) { +#[cfg(any(windows, test))] +fn build_wsl_which_script(tool: &str, jean_managed: Option<&str>) -> String { + let jean_init = if let Some(jean_path) = jean_managed.map(str::trim).filter(|p| !p.is_empty()) { format!( - "jean={jean}; \ - jean_real=$(readlink -f -- \"$jean\" 2>/dev/null || printf '%s' \"$jean\"); \ - while IFS= read -r candidate; do \ - candidate_real=$(readlink -f -- \"$candidate\" 2>/dev/null || printf '%s' \"$candidate\"); \ - if [ \"$candidate_real\" != \"$jean_real\" ]; then printf '%s\\n' \"$candidate\"; exit 0; fi; \ - done < <(type -P -a {tool} 2>/dev/null); \ - exit 1", - jean = shell_single_quote(jean_path), - tool = shell_single_quote(tool), + "jean={}; jean_real=$(readlink -f -- \"$jean\" 2>/dev/null || printf '%s' \"$jean\");", + shell_single_quote(jean_path) ) } else { - format!("type -P -a {}", shell_single_quote(tool)) + "jean=''; jean_real='';".to_string() }; + + format!( + "{jean_init} \ + tool={tool}; \ + emit_candidate() {{ \ + candidate=\"$1\"; \ + [ -n \"$candidate\" ] || return 0; \ + [ -x \"$candidate\" ] || return 0; \ + candidate_real=$(readlink -f -- \"$candidate\" 2>/dev/null || printf '%s' \"$candidate\"); \ + if [ -z \"$jean_real\" ] || [ \"$candidate_real\" != \"$jean_real\" ]; then printf '%s\\n' \"$candidate\"; exit 0; fi; \ + }}; \ + while IFS= read -r candidate; do emit_candidate \"$candidate\"; done < <(type -P -a \"$tool\" 2>/dev/null); \ + for dir in \"$HOME/.local/bin\" \"$HOME/.npm-global/bin\" \"$HOME/.bun/bin\"; do emit_candidate \"$dir/$tool\"; done; \ + exit 1", + tool = shell_single_quote(tool), + ) +} + +/// Resolve the Unix path of a tool inside a WSL distro via `type -P -a` +/// in a login shell, optionally excluding Jean's managed binary. +#[cfg(windows)] +pub fn wsl_which(distro: &str, tool: &str, jean_managed: Option<&str>) -> Option { + let script = build_wsl_which_script(tool, jean_managed); let output = silent_command("wsl.exe") .args(["-d", distro, "--", "bash", "-lc", &script]) .output() @@ -821,6 +835,14 @@ mod tests { ); } + #[test] + fn build_wsl_which_script_falls_back_to_home_local_bin() { + let script = build_wsl_which_script("claude", None); + + assert!(script.contains("$HOME/.local/bin")); + assert!(script.contains("[ -x \"$candidate\" ]")); + } + #[test] fn test_wsl_remove_path_script_quotes_path() { let script = wsl_remove_path_script("/home/o'hara/.local/share/jean/claude-cli"); From 029d1bb39e69d87cba2de4ebac14526996448f29 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:29:12 +0200 Subject: [PATCH 014/359] fix(mcp): support Windows local IPC --- src-tauri/src/chat/jean_mcp.rs | 2 +- src-tauri/src/jean_mcp_socket.rs | 139 ++++++++++++++++++++++++++++++- src-tauri/src/jean_mcp_stdio.rs | 51 +++++++++++- 3 files changed, 185 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/chat/jean_mcp.rs b/src-tauri/src/chat/jean_mcp.rs index 1dcdce31b..a10e585c5 100644 --- a/src-tauri/src/chat/jean_mcp.rs +++ b/src-tauri/src/chat/jean_mcp.rs @@ -4,7 +4,7 @@ //! for callers that explicitly opt into runtime config assembly. Normal Jean //! CLI sessions use the persistent config writers in `jean_mcp_config` so the //! server is visible to users. The stdio helper proxies over a Jean-owned local -//! Unix socket; no HTTP listener/port is required. +//! local IPC; no HTTP listener/port is required. use serde_json::{json, Value}; use tauri::AppHandle; diff --git a/src-tauri/src/jean_mcp_socket.rs b/src-tauri/src/jean_mcp_socket.rs index cba773aca..b992c4d3c 100644 --- a/src-tauri/src/jean_mcp_socket.rs +++ b/src-tauri/src/jean_mcp_socket.rs @@ -21,7 +21,37 @@ pub fn socket_path(app: &AppHandle) -> Result { .path() .app_data_dir() .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; - Ok(dir.join("jean-mcp.sock")) + Ok(platform_socket_path(&dir)) +} + +#[cfg(unix)] +fn platform_socket_path(app_data_dir: &std::path::Path) -> PathBuf { + app_data_dir.join("jean-mcp.sock") +} + +#[cfg(windows)] +fn platform_socket_path(app_data_dir: &std::path::Path) -> PathBuf { + windows_pipe_path_for_app_data(app_data_dir) +} + +#[cfg(not(any(unix, windows)))] +fn platform_socket_path(app_data_dir: &std::path::Path) -> PathBuf { + app_data_dir.join("jean-mcp.sock") +} + +#[cfg(any(windows, test))] +fn windows_pipe_path_for_app_data(app_data_dir: &std::path::Path) -> PathBuf { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + hasher.update(app_data_dir.to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + let suffix = digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + + PathBuf::from(format!(r"\\.\pipe\jean-mcp-{suffix}")) } pub async fn get_socket_status(app: AppHandle) -> (bool, Option, Option) { @@ -124,13 +154,95 @@ pub async fn start_socket_server( }) } -#[cfg(not(unix))] +#[cfg(windows)] +pub async fn start_socket_server( + app: AppHandle, + path: PathBuf, + token: String, +) -> Result { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; + + async fn handle_pipe_connection( + app: AppHandle, + expected_token: String, + mut pipe: NamedPipeServer, + ) { + let mut line = String::new(); + let response = { + let mut reader = BufReader::new(&mut pipe); + match tokio::time::timeout( + std::time::Duration::from_secs(30), + reader.read_line(&mut line), + ) + .await + { + Ok(Ok(0)) => json!({"error":"empty request"}), + Ok(Ok(_)) => handle_socket_request(&app, &expected_token, &line).await, + Ok(Err(e)) => json!({"error": format!("read failed: {e}")}), + Err(_) => json!({"error":"read timeout"}), + } + }; + if let Ok(encoded) = serde_json::to_string(&response) { + let _ = pipe.write_all(encoded.as_bytes()).await; + let _ = pipe.write_all(b"\n").await; + let _ = pipe.flush().await; + } + } + + let pipe_name = path.to_string_lossy().to_string(); + let mut server = ServerOptions::new() + .first_pipe_instance(true) + .create(&pipe_name) + .map_err(|e| format!("Failed to create Jean MCP named pipe {pipe_name}: {e}"))?; + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); + let path_for_task = path.clone(); + let token_for_task = token.clone(); + + tokio::spawn(async move { + log::info!("Jean MCP proxy named pipe listening at {pipe_name}"); + loop { + tokio::select! { + _ = &mut shutdown_rx => { + log::info!("Jean MCP proxy named pipe shutting down"); + break; + } + connected = server.connect() => { + match connected { + Ok(()) => { + let next_server = match ServerOptions::new().create(&pipe_name) { + Ok(next) => next, + Err(e) => { + log::warn!("Jean MCP named pipe recreate failed: {e}"); + break; + } + }; + let connected_server = std::mem::replace(&mut server, next_server); + let app = app.clone(); + let expected_token = token_for_task.clone(); + tokio::spawn(handle_pipe_connection(app, expected_token, connected_server)); + } + Err(e) => log::warn!("Jean MCP named pipe accept failed: {e}"), + } + } + } + } + }); + + Ok(JeanMcpSocketHandle { + shutdown_tx, + path: path_for_task, + token, + }) +} + +#[cfg(not(any(unix, windows)))] pub async fn start_socket_server( _app: AppHandle, _path: PathBuf, _token: String, ) -> Result { - Err("Jean MCP currently requires Unix domain sockets".to_string()) + Err("Jean MCP local IPC is not supported on this platform".to_string()) } async fn handle_socket_request(app: &AppHandle, expected_token: &str, line: &str) -> Value { @@ -158,3 +270,24 @@ async fn handle_socket_request(app: &AppHandle, expected_token: &str, line: &str Err(e) => jsonrpc_error(None, e.code, &e.message), } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + #[test] + fn windows_pipe_path_is_stable_and_named_pipe_safe() { + let one = + super::windows_pipe_path_for_app_data(Path::new(r"C:\Users\Ada\AppData\Roaming\Jean")); + let two = + super::windows_pipe_path_for_app_data(Path::new(r"C:\Users\Ada\AppData\Roaming\Jean")); + let other = super::windows_pipe_path_for_app_data(Path::new( + r"C:\Users\Ada\AppData\Roaming\JeanDev", + )); + + assert_eq!(one, two); + assert_ne!(one, other); + assert!(one.to_string_lossy().starts_with(r"\\.\pipe\jean-mcp-")); + assert!(!one.to_string_lossy().contains(':')); + } +} diff --git a/src-tauri/src/jean_mcp_stdio.rs b/src-tauri/src/jean_mcp_stdio.rs index a481f5637..a5d0c393c 100644 --- a/src-tauri/src/jean_mcp_stdio.rs +++ b/src-tauri/src/jean_mcp_stdio.rs @@ -1,7 +1,7 @@ //! Stdio MCP transport for Jean. //! //! This process is launched by a local CLI as an MCP server. It proxies -//! tools/call requests over a Jean-owned local Unix socket to the already +//! tools/call requests over Jean-owned local IPC to the already //! running desktop app, avoiding HTTP ports while preserving in-process app //! command dispatch in the parent. @@ -103,9 +103,54 @@ fn proxy_to_parent(socket: &str, request: Value) -> Result { Ok(response.get("result").cloned().unwrap_or(Value::Null)) } -#[cfg(not(unix))] +#[cfg(windows)] +fn proxy_to_parent(socket: &str, request: Value) -> Result { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::windows::named_pipe::ClientOptions; + use tokio::runtime::Builder; + use tokio::time::{timeout, Duration}; + + let encoded = serde_json::to_string(&request) + .map_err(|e| format!("Failed to encode Jean MCP pipe request: {e}"))?; + let runtime = Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .map_err(|e| format!("Failed to create Jean MCP pipe runtime: {e}"))?; + + let response = runtime.block_on(async { + let mut pipe = ClientOptions::new() + .open(socket) + .map_err(|e| format!("Failed to connect Jean MCP named pipe {socket}: {e}"))?; + timeout(Duration::from_secs(30), async { + pipe.write_all(encoded.as_bytes()).await?; + pipe.write_all(b"\n").await?; + pipe.flush().await + }) + .await + .map_err(|_| "Timed out writing Jean MCP pipe request".to_string())? + .map_err(|e| format!("Failed to write Jean MCP pipe request: {e}"))?; + + let mut reader = BufReader::new(pipe); + let mut line = String::new(); + timeout(Duration::from_secs(120), reader.read_line(&mut line)) + .await + .map_err(|_| "Timed out reading Jean MCP pipe response".to_string())? + .map_err(|e| format!("Failed to read Jean MCP pipe response: {e}"))?; + + serde_json::from_str::(&line) + .map_err(|e| format!("Failed to parse Jean MCP pipe response: {e}")) + })?; + + if let Some(error) = parent_error_message(&response) { + return Err(error); + } + Ok(response.get("result").cloned().unwrap_or(Value::Null)) +} + +#[cfg(not(any(unix, windows)))] fn proxy_to_parent(_socket: &str, _request: Value) -> Result { - Err("Jean MCP currently requires a Unix domain socket".to_string()) + Err("Jean MCP local IPC is not supported on this platform".to_string()) } fn parent_error_message(response: &Value) -> Option { From 13a0fe9d1a82a499b5f7d471a44f291404c1c016 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:29:20 +0200 Subject: [PATCH 015/359] fix(window): add Linux resize handles --- src/components/layout/MainWindow.tsx | 5 +- .../layout/WindowResizeHandles.test.tsx | 63 ++++++++++ src/components/layout/WindowResizeHandles.tsx | 112 ++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/components/layout/WindowResizeHandles.test.tsx create mode 100644 src/components/layout/WindowResizeHandles.tsx diff --git a/src/components/layout/MainWindow.tsx b/src/components/layout/MainWindow.tsx index 99e21abb9..958c5d1a6 100644 --- a/src/components/layout/MainWindow.tsx +++ b/src/components/layout/MainWindow.tsx @@ -20,6 +20,7 @@ import { CommandPalette } from '@/components/command-palette/CommandPalette' import { QuitConfirmationDialog } from './QuitConfirmationDialog' import { BranchConflictDialog } from '@/components/worktree/BranchConflictDialog' import { TeardownOutputDialog } from '@/components/worktree/TeardownOutputDialog' +import { WindowResizeHandles } from './WindowResizeHandles' // Lazy-loaded heavy modals (code splitting) const LeftSideBar = lazy(() => @@ -189,7 +190,7 @@ import { useWorktreeEvents, } from '@/services/projects' import { isNativeApp } from '@/lib/environment' -import { isWindows } from '@/lib/platform' +import { isLinux, isWindows } from '@/lib/platform' // Left sidebar resize constraints (pixels) const MIN_SIDEBAR_WIDTH = 150 @@ -460,6 +461,8 @@ export function MainWindow() { roundedClass )} > + {isNativeApp() && isLinux && } + {/* Touch swipe-down pull indicator */} {isTouch && swipeDown.isSwiping && (

({ + getCurrentWindow: () => ({ startResizeDragging }), +})) + +let maximized = false + +vi.mock('@/hooks/use-window-maximized', () => ({ + useWindowMaximized: () => maximized, +})) + +describe('WindowResizeHandles', () => { + beforeEach(() => { + maximized = false + startResizeDragging.mockReset() + }) + + it('starts native resize dragging for each edge and corner', () => { + render() + + const directions = [ + 'NorthWest', + 'North', + 'NorthEast', + 'East', + 'SouthEast', + 'South', + 'SouthWest', + 'West', + ] as const + + for (const direction of directions) { + fireEvent.mouseDown(screen.getByTestId(`window-resize-${direction}`), { + button: 0, + }) + } + + expect(startResizeDragging.mock.calls.map(call => call[0])).toEqual( + directions + ) + }) + + it('ignores non-primary mouse buttons', () => { + render() + + fireEvent.mouseDown(screen.getByTestId('window-resize-East'), { button: 1 }) + + expect(startResizeDragging).not.toHaveBeenCalled() + }) + + it('does not render handles while maximized', () => { + maximized = true + + render() + + expect(screen.queryByTestId('window-resize-East')).toBeNull() + }) +}) diff --git a/src/components/layout/WindowResizeHandles.tsx b/src/components/layout/WindowResizeHandles.tsx new file mode 100644 index 000000000..44150243f --- /dev/null +++ b/src/components/layout/WindowResizeHandles.tsx @@ -0,0 +1,112 @@ +import type React from 'react' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { useWindowMaximized } from '@/hooks/use-window-maximized' + +type ResizeDirection = + | 'East' + | 'North' + | 'NorthEast' + | 'NorthWest' + | 'South' + | 'SouthEast' + | 'SouthWest' + | 'West' + +interface Handle { + direction: ResizeDirection + style: React.CSSProperties +} + +const EDGE_SIZE = 6 +const CORNER_SIZE = 12 + +const cursorByDirection: Record = { + North: 'ns-resize', + South: 'ns-resize', + East: 'ew-resize', + West: 'ew-resize', + NorthEast: 'nesw-resize', + SouthWest: 'nesw-resize', + NorthWest: 'nwse-resize', + SouthEast: 'nwse-resize', +} + +const handles: Handle[] = [ + { + direction: 'NorthWest', + style: { top: 0, left: 0, width: CORNER_SIZE, height: CORNER_SIZE }, + }, + { + direction: 'North', + style: { top: 0, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_SIZE }, + }, + { + direction: 'NorthEast', + style: { top: 0, right: 0, width: CORNER_SIZE, height: CORNER_SIZE }, + }, + { + direction: 'East', + style: { + top: CORNER_SIZE, + right: 0, + bottom: CORNER_SIZE, + width: EDGE_SIZE, + }, + }, + { + direction: 'SouthEast', + style: { right: 0, bottom: 0, width: CORNER_SIZE, height: CORNER_SIZE }, + }, + { + direction: 'South', + style: { + right: CORNER_SIZE, + bottom: 0, + left: CORNER_SIZE, + height: EDGE_SIZE, + }, + }, + { + direction: 'SouthWest', + style: { bottom: 0, left: 0, width: CORNER_SIZE, height: CORNER_SIZE }, + }, + { + direction: 'West', + style: { top: CORNER_SIZE, bottom: CORNER_SIZE, left: 0, width: EDGE_SIZE }, + }, +] + +export function WindowResizeHandles() { + const isMaximized = useWindowMaximized() + + if (isMaximized) return null + + const startResize = + (direction: ResizeDirection) => (event: React.MouseEvent) => { + if (event.button !== 0) return + + event.preventDefault() + event.stopPropagation() + void getCurrentWindow().startResizeDragging(direction) + } + + return ( + <> + {handles.map(handle => ( +