Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -132,6 +132,12 @@ Electron wrapper for macOS, Linux, and Windows.
|
|||||||
- Native file access for workspace integration
|
- Native file access for workspace integration
|
||||||
- Same WebSocket client as mobile app
|
- Same WebSocket client as mobile app
|
||||||
|
|
||||||
|
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
|
||||||
|
|
||||||
|
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||||
|
>
|
||||||
|
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||||
|
|
||||||
### `packages/website` — Marketing site
|
### `packages/website` — Marketing site
|
||||||
|
|
||||||
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||||
|
|||||||
@@ -577,7 +577,7 @@ Each entry in the `models` array:
|
|||||||
|
|
||||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||||
|
|
||||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
|
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||||
|
|
||||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export {
|
|||||||
AssistantMarkdownCodeLink,
|
AssistantMarkdownCodeLink,
|
||||||
AssistantMarkdownLink,
|
AssistantMarkdownLink,
|
||||||
} from "./link";
|
} from "./link";
|
||||||
|
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
|
||||||
export {
|
export {
|
||||||
classifyAssistantFileLink,
|
classifyAssistantFileLink,
|
||||||
normalizeInlinePathTarget,
|
normalizeInlinePathTarget,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { TextProps } from "react-native";
|
||||||
|
|
||||||
|
// Carries a link's press handler down to the leaf text spans that render its
|
||||||
|
// label. On iOS an assistant link is a nested UITextView span, and
|
||||||
|
// react-native-uitextview only attaches onPress to the *string* children it
|
||||||
|
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
|
||||||
|
// MarkdownInheritedText spans markdown produces for link text) pass through
|
||||||
|
// untouched, so an onPress placed on the wrapping span never reaches a tappable
|
||||||
|
// native node. Threading the handler through context lets each leaf span hand
|
||||||
|
// onPress to its own string children, where the native tap recognizer can find
|
||||||
|
// it. Provided only on iOS (Android/web links tap fine via their own paths).
|
||||||
|
export interface AssistantLinkPress {
|
||||||
|
onPress: () => void;
|
||||||
|
accessibilityRole?: TextProps["accessibilityRole"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
|
||||||
|
|
||||||
|
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
|
||||||
|
|
||||||
|
export function useAssistantLinkPress(): AssistantLinkPress | null {
|
||||||
|
return useContext(AssistantLinkPressContext);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||||
|
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
@@ -56,23 +58,42 @@ export function AssistantMarkdownLink({
|
|||||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||||
[style, hovered],
|
[style, hovered],
|
||||||
);
|
);
|
||||||
|
const linkPress = useMemo<AssistantLinkPress>(
|
||||||
|
() => ({ onPress, accessibilityRole: "link" }),
|
||||||
|
[onPress],
|
||||||
|
);
|
||||||
|
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
|
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
|
||||||
// inside the paragraph's native UITextView, and a plain <Text> nested there
|
// inside the paragraph's native UITextView, and a plain <Text> nested there
|
||||||
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
||||||
// (the link disappears). The span composes correctly and stays selectable;
|
// (the link disappears). The span composes correctly and stays selectable.
|
||||||
// onPress is forwarded (reliable tap-to-open on iOS is tracked by #21).
|
//
|
||||||
|
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
|
||||||
|
// children it turns into RNUITextViewChild nodes — the element children that
|
||||||
|
// markdown emits for link text pass through untouched, so an onPress placed
|
||||||
|
// here never reaches a tappable native node. We thread it down through
|
||||||
|
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
|
||||||
|
// own string children, where the native tap recognizer can find it. iOS
|
||||||
|
// only: Android forwards onPress through nested <Text> already, and web uses
|
||||||
|
// the <a> path below.
|
||||||
|
const span = (
|
||||||
|
<MarkdownTextSpan
|
||||||
|
accessibilityRole="link"
|
||||||
|
monoSurface={monoSurface}
|
||||||
|
onPress={onPress}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MarkdownTextSpan>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||||
<MarkdownTextSpan
|
{Platform.OS === "ios" ? (
|
||||||
accessibilityRole="link"
|
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
|
||||||
monoSurface={monoSurface}
|
) : (
|
||||||
onPress={onPress}
|
span
|
||||||
style={style}
|
)}
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</MarkdownTextSpan>
|
|
||||||
</FileLinkHoverTooltip>
|
</FileLinkHoverTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ interface MarkdownTextSpanProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
// Links route through this span too (see assistant-file-links/link.tsx). A
|
// Links route through this span too (see assistant-file-links/link.tsx). A
|
||||||
// plain <Text> nested in the paragraph UITextView is dropped, so the link
|
// plain <Text> nested in the paragraph UITextView is dropped, so the link
|
||||||
// must be a UITextView span to be visible. onPress is forwarded best-effort:
|
// must be a UITextView span to be visible. onPress is wired onto the leaf
|
||||||
// react-native-uitextview nulls onPress on the root native view, so reliable
|
// string children here: react-native-uitextview attaches it to the
|
||||||
// tap-to-open is still tracked by #21 — but visible+selectable text beats an
|
// RNUITextViewChild nodes it builds from string content, which the native tap
|
||||||
// invisible link.
|
// recognizer dispatches to. The link's handler reaches these leaf spans via
|
||||||
|
// AssistantLinkPressProvider (see assistant-file-links/link-press-context).
|
||||||
onPress?: TextProps["onPress"];
|
onPress?: TextProps["onPress"];
|
||||||
accessibilityRole?: TextProps["accessibilityRole"];
|
accessibilityRole?: TextProps["accessibilityRole"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import {
|
|||||||
AssistantMarkdownLink,
|
AssistantMarkdownLink,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
useAssistantFileLinkActions,
|
useAssistantFileLinkActions,
|
||||||
|
useAssistantLinkPress,
|
||||||
} from "@/assistant-file-links";
|
} from "@/assistant-file-links";
|
||||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
@@ -1532,8 +1533,19 @@ function MarkdownInheritedText({
|
|||||||
() => [inheritedStyles, textStyle, overrideStyle],
|
() => [inheritedStyles, textStyle, overrideStyle],
|
||||||
[inheritedStyles, textStyle, overrideStyle],
|
[inheritedStyles, textStyle, overrideStyle],
|
||||||
);
|
);
|
||||||
|
// When this span renders link label text on iOS, pick up the link's press
|
||||||
|
// handler from context and hand it to MarkdownTextSpan, which forwards it to
|
||||||
|
// the leaf string children react-native-uitextview makes tappable. Null
|
||||||
|
// outside a link (and on every other platform, where no provider mounts), so
|
||||||
|
// ordinary text is unaffected. See assistant-file-links/link-press-context.
|
||||||
|
const linkPress = useAssistantLinkPress();
|
||||||
return (
|
return (
|
||||||
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
|
<MarkdownTextSpan
|
||||||
|
monoSurface={monoSurface}
|
||||||
|
style={style}
|
||||||
|
onPress={linkPress?.onPress}
|
||||||
|
accessibilityRole={linkPress?.accessibilityRole}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</MarkdownTextSpan>
|
</MarkdownTextSpan>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -128,7 +128,12 @@ import {
|
|||||||
archiveWorkspaceOptimistically,
|
archiveWorkspaceOptimistically,
|
||||||
archiveWorkspacesOptimistically,
|
archiveWorkspacesOptimistically,
|
||||||
} from "@/workspace/workspace-archive";
|
} from "@/workspace/workspace-archive";
|
||||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
import {
|
||||||
|
isWeb as platformIsWeb,
|
||||||
|
isNative as platformIsNative,
|
||||||
|
getIsElectron,
|
||||||
|
} from "@/constants/platform";
|
||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
|
||||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||||
|
|
||||||
@@ -153,14 +158,24 @@ const ThemedCopy = withUnistyles(Copy);
|
|||||||
const ThemedArchive = withUnistyles(Archive);
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
const ThemedPencil = withUnistyles(Pencil);
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
});
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
const redColorMapping = (theme: Theme) => ({
|
||||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
color: theme.colors.palette.red[500],
|
||||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
});
|
||||||
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
|
const amberColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.palette.amber[500],
|
||||||
|
});
|
||||||
|
const greenColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.palette.green[500],
|
||||||
|
});
|
||||||
|
const purpleColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.palette.purple[500],
|
||||||
|
});
|
||||||
const syncedLoaderColorMapping = (theme: Theme) => ({
|
const syncedLoaderColorMapping = (theme: Theme) => ({
|
||||||
color:
|
color:
|
||||||
theme.colorScheme === "light"
|
theme.colorScheme === "light"
|
||||||
@@ -515,6 +530,7 @@ function ProjectRowTrailingActions({
|
|||||||
>
|
>
|
||||||
<ProjectKebabMenu
|
<ProjectKebabMenu
|
||||||
projectKey={project.projectKey}
|
projectKey={project.projectKey}
|
||||||
|
projectPath={project.iconWorkingDir}
|
||||||
onRemoveProject={onRemoveProject}
|
onRemoveProject={onRemoveProject}
|
||||||
removeProjectStatus={removeProjectStatus}
|
removeProjectStatus={removeProjectStatus}
|
||||||
/>
|
/>
|
||||||
@@ -532,6 +548,9 @@ const markAsReadLeadingIcon = (
|
|||||||
);
|
);
|
||||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const openInNewWindowLeadingIcon = (
|
||||||
|
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
|
);
|
||||||
|
|
||||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -544,18 +563,35 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
|||||||
|
|
||||||
function ProjectKebabMenu({
|
function ProjectKebabMenu({
|
||||||
projectKey,
|
projectKey,
|
||||||
|
projectPath,
|
||||||
onRemoveProject,
|
onRemoveProject,
|
||||||
removeProjectStatus,
|
removeProjectStatus,
|
||||||
}: {
|
}: {
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
|
projectPath: string;
|
||||||
onRemoveProject: () => void;
|
onRemoveProject: () => void;
|
||||||
removeProjectStatus: "idle" | "pending" | "success";
|
removeProjectStatus: "idle" | "pending" | "success";
|
||||||
}) {
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
const handleOpenProjectSettings = useCallback(() => {
|
const handleOpenProjectSettings = useCallback(() => {
|
||||||
if (projectKey.trim().length === 0) return;
|
if (projectKey.trim().length === 0) return;
|
||||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||||
}, [projectKey]);
|
}, [projectKey]);
|
||||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||||
|
// Desktop-only: open a second window that lands on this project via the same
|
||||||
|
// open-project flow as a CLI launch. The project stays visible here too — no
|
||||||
|
// ownership, no move.
|
||||||
|
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
|
||||||
|
const handleOpenInNewWindow = useCallback(() => {
|
||||||
|
const trimmedPath = projectPath.trim();
|
||||||
|
if (trimmedPath.length === 0) return;
|
||||||
|
void getDesktopHost()
|
||||||
|
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
|
||||||
|
?.catch((error) => {
|
||||||
|
console.warn("[sidebar] openNew failed", error);
|
||||||
|
toast.error("Couldn't open a new window");
|
||||||
|
});
|
||||||
|
}, [projectPath, toast]);
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -577,6 +613,15 @@ function ProjectKebabMenu({
|
|||||||
Open project settings
|
Open project settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canOpenInNewWindow ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-project-menu-open-new-window-${projectKey}`}
|
||||||
|
leading={openInNewWindowLeadingIcon}
|
||||||
|
onSelect={handleOpenInNewWindow}
|
||||||
|
>
|
||||||
|
Open in new window
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||||
leading={trash2LeadingIcon}
|
leading={trash2LeadingIcon}
|
||||||
@@ -2398,7 +2443,10 @@ function SidebarStatusModeWrapper({
|
|||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const hydratedWorkspaces = useStatusModeWorkspaceEntries({ serverId, projects });
|
const hydratedWorkspaces = useStatusModeWorkspaceEntries({
|
||||||
|
serverId,
|
||||||
|
projects,
|
||||||
|
});
|
||||||
const projectNamesByKey = useProjectNamesMap(serverId);
|
const projectNamesByKey = useProjectNamesMap(serverId);
|
||||||
const showShortcutBadges = useShowShortcutBadges();
|
const showShortcutBadges = useShowShortcutBadges();
|
||||||
|
|
||||||
@@ -2459,7 +2507,10 @@ function ProjectModeList({
|
|||||||
[parentGestureRef],
|
[parentGestureRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectIconByProjectKey = useProjectIconDataByProjectKey({ serverId, projects });
|
const projectIconByProjectKey = useProjectIconDataByProjectKey({
|
||||||
|
serverId,
|
||||||
|
projects,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeouts = creatingWorkspaceTimeoutsRef.current;
|
const timeouts = creatingWorkspaceTimeoutsRef.current;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface DesktopWindowBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopWindowModuleBridge {
|
export interface DesktopWindowModuleBridge {
|
||||||
|
openNew?: (options?: { pendingOpenProjectPath?: string | null }) => Promise<void>;
|
||||||
getCurrentWindow?: () => DesktopWindowBridge;
|
getCurrentWindow?: () => DesktopWindowBridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ node_modules/
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Isolated dev environment (PASEO_HOME + Electron userData) created by scripts/dev.sh
|
||||||
|
.dev/
|
||||||
|
|||||||
@@ -49,12 +49,60 @@ $env:PASEO_ELECTRON_FLAGS = "$($ExistingElectronFlags)--remote-debugging-port=$R
|
|||||||
# the daemon binds to localhost and this script is never used for production.
|
# the daemon binds to localhost and this script is never used for production.
|
||||||
$env:PASEO_CORS_ORIGINS = "*"
|
$env:PASEO_CORS_ORIGINS = "*"
|
||||||
|
|
||||||
|
# Fully isolate the dev instance from a production Paseo install so `npm run dev`
|
||||||
|
# works while the installed app is open. Without this the dev build loses the
|
||||||
|
# Electron single-instance lock to the installed app and quits, and ends up
|
||||||
|
# pointed at the production daemon, whose CORS allowlist rejects the Metro origin.
|
||||||
|
# PASEO_HOME defaults to a script-managed dev home. If you override it (to point
|
||||||
|
# dev at real data), we DON'T touch your config.json — only the managed home gets
|
||||||
|
# its daemon config seeded below, so we never rewrite a production config.
|
||||||
|
$DevStateDir = "$DesktopDir\.dev"
|
||||||
|
if (-not $env:PASEO_HOME) {
|
||||||
|
$env:PASEO_HOME = "$DevStateDir\paseo-home"
|
||||||
|
$PaseoHomeManaged = $true
|
||||||
|
} else {
|
||||||
|
$PaseoHomeManaged = $false
|
||||||
|
}
|
||||||
|
if (-not $env:PASEO_ELECTRON_USER_DATA_DIR) { $env:PASEO_ELECTRON_USER_DATA_DIR = "$DevStateDir\user-data" }
|
||||||
|
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME, $env:PASEO_ELECTRON_USER_DATA_DIR | Out-Null
|
||||||
|
|
||||||
|
$DevDaemonPort = if ($env:PASEO_DEV_DAEMON_PORT) { $env:PASEO_DEV_DAEMON_PORT } else { "6788" }
|
||||||
|
if (-not $env:PASEO_LISTEN) { $env:PASEO_LISTEN = "127.0.0.1:$DevDaemonPort" }
|
||||||
|
|
||||||
|
# Seed the isolated daemon config. The desktop daemon-manager decides whether a
|
||||||
|
# daemon is already running by reading `daemon.listen` from this config.json
|
||||||
|
# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without
|
||||||
|
# this it reads the default 6767, finds a production daemon there, and connects
|
||||||
|
# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin
|
||||||
|
# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon.
|
||||||
|
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||||
|
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||||
|
if ($PaseoHomeManaged) {
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const [path, port] = [process.argv[1], process.argv[2]];
|
||||||
|
let cfg = {};
|
||||||
|
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||||
|
cfg.version = cfg.version || 1;
|
||||||
|
cfg.daemon = cfg.daemon || {};
|
||||||
|
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||||
|
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||||
|
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||||
|
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||||
|
' "$($env:PASEO_HOME)/config.json" $DevDaemonPort
|
||||||
|
} else {
|
||||||
|
Write-Host " (custom PASEO_HOME - leaving its config.json untouched)"
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host @"
|
Write-Host @"
|
||||||
======================================================
|
======================================================
|
||||||
Paseo Desktop Dev (Windows)
|
Paseo Desktop Dev (Windows)
|
||||||
======================================================
|
======================================================
|
||||||
Metro: http://localhost:$($env:EXPO_PORT)
|
Metro: http://localhost:$($env:EXPO_PORT)
|
||||||
CDP: http://127.0.0.1:$RemoteDebuggingPort
|
CDP: http://127.0.0.1:$RemoteDebuggingPort
|
||||||
|
Daemon: $($env:PASEO_LISTEN) (isolated)
|
||||||
|
PASEO_HOME: $($env:PASEO_HOME)
|
||||||
|
userData: $($env:PASEO_ELECTRON_USER_DATA_DIR)
|
||||||
======================================================
|
======================================================
|
||||||
"@
|
"@
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,63 @@ export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--re
|
|||||||
# and the daemon still binds to localhost.
|
# and the daemon still binds to localhost.
|
||||||
export PASEO_CORS_ORIGINS="*"
|
export PASEO_CORS_ORIGINS="*"
|
||||||
|
|
||||||
|
# Fully isolate the dev instance from a production Paseo install so `npm run dev`
|
||||||
|
# works while the installed app is open. Without this the dev build (a) loses the
|
||||||
|
# Electron single-instance lock to the installed app and quits, and (b) ends up
|
||||||
|
# pointed at the production daemon, whose CORS allowlist rejects the Metro origin.
|
||||||
|
# PASEO_HOME defaults to a script-managed dev home. If you override it (to point
|
||||||
|
# dev at real data), we DON'T touch your config.json — only the managed home gets
|
||||||
|
# its daemon config seeded below, so we never rewrite a production ~/.paseo config.
|
||||||
|
# - PASEO_ELECTRON_USER_DATA_DIR: a separate Electron profile → separate
|
||||||
|
# single-instance lock, so dev and the installed app coexist.
|
||||||
|
# - PASEO_LISTEN: a distinct port so the dev daemon never collides with prod's 6767.
|
||||||
|
DEV_STATE_DIR="$DESKTOP_DIR/.dev"
|
||||||
|
if [ -n "${PASEO_HOME:-}" ]; then
|
||||||
|
PASEO_HOME_MANAGED=0
|
||||||
|
else
|
||||||
|
PASEO_HOME="$DEV_STATE_DIR/paseo-home"
|
||||||
|
PASEO_HOME_MANAGED=1
|
||||||
|
fi
|
||||||
|
export PASEO_HOME
|
||||||
|
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_STATE_DIR/user-data}"
|
||||||
|
mkdir -p "$PASEO_HOME" "$PASEO_ELECTRON_USER_DATA_DIR"
|
||||||
|
|
||||||
|
DEV_DAEMON_PORT="${PASEO_DEV_DAEMON_PORT:-6788}"
|
||||||
|
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:$DEV_DAEMON_PORT}"
|
||||||
|
|
||||||
|
# Seed the isolated daemon config. The desktop daemon-manager decides whether a
|
||||||
|
# daemon is already running by reading `daemon.listen` from this config.json
|
||||||
|
# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without
|
||||||
|
# this it reads the default 6767, finds a production daemon there, and connects
|
||||||
|
# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin
|
||||||
|
# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon.
|
||||||
|
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||||
|
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||||
|
if [ "$PASEO_HOME_MANAGED" = "1" ]; then
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const [path, port] = [process.argv[1], process.argv[2]];
|
||||||
|
let cfg = {};
|
||||||
|
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||||
|
cfg.version = cfg.version || 1;
|
||||||
|
cfg.daemon = cfg.daemon || {};
|
||||||
|
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||||
|
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||||
|
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||||
|
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||||
|
' "$PASEO_HOME/config.json" "$DEV_DAEMON_PORT"
|
||||||
|
else
|
||||||
|
echo " (custom PASEO_HOME — leaving its config.json untouched)"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "══════════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════════"
|
||||||
echo " Paseo Desktop Dev"
|
echo " Paseo Desktop Dev"
|
||||||
echo "══════════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════════"
|
||||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
echo " Metro: http://localhost:${EXPO_PORT}"
|
||||||
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
|
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
|
||||||
|
echo " Daemon: ${PASEO_LISTEN} (isolated)"
|
||||||
|
echo " PASEO_HOME: ${PASEO_HOME}"
|
||||||
|
echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}"
|
||||||
echo "══════════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════════"
|
||||||
|
|
||||||
# Launch Metro + Electron together, kill both on exit
|
# Launch Metro + Electron together, kill both on exit
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ interface ShowContextMenuInput {
|
|||||||
hasSelection?: boolean;
|
hasSelection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApplicationMenuOptions {
|
||||||
|
onNewWindow: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
function withBrowserWindow(
|
function withBrowserWindow(
|
||||||
callback: (win: BrowserWindow) => void,
|
callback: (win: BrowserWindow) => void,
|
||||||
): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void {
|
): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void {
|
||||||
@@ -41,10 +45,12 @@ function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCac
|
|||||||
win.webContents.reload();
|
win.webContents.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupApplicationMenu(): void {
|
function buildApplicationMenuTemplate(
|
||||||
|
options: ApplicationMenuOptions,
|
||||||
|
): Electron.MenuItemConstructorOptions[] {
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
|
|
||||||
const template: Electron.MenuItemConstructorOptions[] = [
|
return [
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -63,6 +69,18 @@ export function setupApplicationMenu(): void {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
{
|
||||||
|
label: "File",
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: "New Window",
|
||||||
|
accelerator: "CmdOrCtrl+Shift+N",
|
||||||
|
click: () => {
|
||||||
|
options.onNewWindow();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Edit",
|
label: "Edit",
|
||||||
submenu: [
|
submenu: [
|
||||||
@@ -130,8 +148,10 @@ export function setupApplicationMenu(): void {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const menu = Menu.buildFromTemplate(template);
|
export function setupApplicationMenu(options: ApplicationMenuOptions): void {
|
||||||
|
const menu = Menu.buildFromTemplate(buildApplicationMenuTemplate(options));
|
||||||
Menu.setApplicationMenu(menu);
|
Menu.setApplicationMenu(menu);
|
||||||
|
|
||||||
ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => {
|
ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
setWorkspaceActivePaseoBrowserId,
|
setWorkspaceActivePaseoBrowserId,
|
||||||
} from "./features/browser-webviews.js";
|
} from "./features/browser-webviews.js";
|
||||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||||
|
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
|
||||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||||
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
|
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
|
||||||
import {
|
import {
|
||||||
@@ -94,7 +95,6 @@ function preventUnsafeBrowserWebviewNavigation(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
|
||||||
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
|
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
|
||||||
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
|
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
|
||||||
|
|
||||||
@@ -257,6 +257,12 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
|||||||
isDefaultApp: process.defaultApp,
|
isDefaultApp: process.defaultApp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Each window pulls its own pending open-project path on mount, keyed by
|
||||||
|
// webContents id, so deep-linked windows (second-instance launches, the
|
||||||
|
// in-app "Open in new window" action) land on the right project without
|
||||||
|
// racing a global.
|
||||||
|
const pendingOpenProjectStore = new PendingOpenProjectStore();
|
||||||
|
|
||||||
if (PASEO_DEBUG) {
|
if (PASEO_DEBUG) {
|
||||||
log.info("[open-project] argv:", process.argv);
|
log.info("[open-project] argv:", process.argv);
|
||||||
log.info("[open-project] isDefaultApp:", process.defaultApp);
|
log.info("[open-project] isDefaultApp:", process.defaultApp);
|
||||||
@@ -265,10 +271,13 @@ if (PASEO_DEBUG) {
|
|||||||
|
|
||||||
// The renderer pulls the pending path on mount via IPC — this avoids
|
// The renderer pulls the pending path on mount via IPC — this avoids
|
||||||
// a race where the push event arrives before React registers its listener.
|
// a race where the push event arrives before React registers its listener.
|
||||||
ipcMain.handle("paseo:get-pending-open-project", () => {
|
ipcMain.handle("paseo:get-pending-open-project", (event) => {
|
||||||
log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath);
|
const webContentsId = event.sender.id;
|
||||||
const result = pendingOpenProjectPath;
|
const result = pendingOpenProjectStore.take(webContentsId);
|
||||||
pendingOpenProjectPath = null;
|
log.info("[open-project] renderer requested pending path:", {
|
||||||
|
webContentsId,
|
||||||
|
pendingPath: result,
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,7 +335,10 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow
|
|||||||
});
|
});
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
{
|
||||||
|
scheme: APP_SCHEME,
|
||||||
|
privileges: { standard: true, secure: true, supportFetchAPI: true },
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -395,12 +407,24 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
|
|||||||
return [primary, ...others].map((display) => display.workArea);
|
return [primary, ...others].map((display) => display.workArea);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createMainWindow(): Promise<void> {
|
async function createWindow(
|
||||||
|
options: {
|
||||||
|
pendingOpenProjectPath?: string | null;
|
||||||
|
restoreWindowState?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<BrowserWindow> {
|
||||||
const iconPath = getWindowIconPath();
|
const iconPath = getWindowIconPath();
|
||||||
const systemTheme = resolveSystemWindowTheme();
|
const systemTheme = resolveSystemWindowTheme();
|
||||||
|
|
||||||
const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") });
|
// Only the first window of a session restores and persists saved geometry.
|
||||||
const savedWindowState = await windowStateStore.load();
|
// Additional windows (⌘N, second-instance, "Open in new window") open at the
|
||||||
|
// default size and let the OS cascade them, so they neither stack on top of
|
||||||
|
// the restored window nor fight over the single window-state store.
|
||||||
|
const restoreWindowState = options.restoreWindowState ?? false;
|
||||||
|
const windowStateStore = restoreWindowState
|
||||||
|
? createWindowStateStore({ userDataPath: app.getPath("userData") })
|
||||||
|
: null;
|
||||||
|
const savedWindowState = windowStateStore ? await windowStateStore.load() : null;
|
||||||
const restoredWindowState = savedWindowState
|
const restoredWindowState = savedWindowState
|
||||||
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
|
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
|
||||||
: null;
|
: null;
|
||||||
@@ -424,6 +448,12 @@ async function createMainWindow(): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webContentsId = mainWindow.webContents.id;
|
||||||
|
pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath);
|
||||||
|
mainWindow.on("closed", () => {
|
||||||
|
pendingOpenProjectStore.delete(webContentsId);
|
||||||
|
});
|
||||||
|
|
||||||
if (devWorktreeName) {
|
if (devWorktreeName) {
|
||||||
app.dock?.setBadge(devWorktreeName);
|
app.dock?.setBadge(devWorktreeName);
|
||||||
}
|
}
|
||||||
@@ -434,7 +464,9 @@ async function createMainWindow(): Promise<void> {
|
|||||||
|
|
||||||
setupDarwinCompositorWatchdog(mainWindow);
|
setupDarwinCompositorWatchdog(mainWindow);
|
||||||
setupWindowResizeEvents(mainWindow);
|
setupWindowResizeEvents(mainWindow);
|
||||||
setupWindowStatePersistence(mainWindow, windowStateStore);
|
if (windowStateStore) {
|
||||||
|
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||||
|
}
|
||||||
setupDefaultContextMenu(mainWindow);
|
setupDefaultContextMenu(mainWindow);
|
||||||
setupDragDropPrevention(mainWindow);
|
setupDragDropPrevention(mainWindow);
|
||||||
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
||||||
@@ -531,31 +563,27 @@ async function createMainWindow(): Promise<void> {
|
|||||||
const { loadReactDevTools } = await import("./features/react-devtools.js");
|
const { loadReactDevTools } = await import("./features/react-devtools.js");
|
||||||
await loadReactDevTools();
|
await loadReactDevTools();
|
||||||
await mainWindow.loadURL(DEV_SERVER_URL);
|
await mainWindow.loadURL(DEV_SERVER_URL);
|
||||||
return;
|
return mainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
await mainWindow.loadURL(`${APP_SCHEME}://app/`);
|
await mainWindow.loadURL(`${APP_SCHEME}://app/`);
|
||||||
}
|
return mainWindow;
|
||||||
|
|
||||||
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
|
|
||||||
const send = () => {
|
|
||||||
log.info("[open-project] sending event to renderer:", projectPath);
|
|
||||||
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (win.webContents.isLoadingMainFrame()) {
|
|
||||||
log.info("[open-project] waiting for did-finish-load before sending event");
|
|
||||||
win.webContents.once("did-finish-load", send);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App lifecycle
|
// App lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Resolves once bootstrap() has registered the custom protocol handler and IPC
|
||||||
|
// handlers and created the first window. second-instance window creation waits
|
||||||
|
// on this rather than app.whenReady(): in packaged mode createWindow loads
|
||||||
|
// `paseo://app/`, which fails if the protocol handler isn't registered yet, and
|
||||||
|
// a second instance can arrive mid-cold-start.
|
||||||
|
let resolveBootstrapComplete: () => void;
|
||||||
|
const bootstrapComplete = new Promise<void>((resolve) => {
|
||||||
|
resolveBootstrapComplete = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
function setupSingleInstanceLock(): boolean {
|
function setupSingleInstanceLock(): boolean {
|
||||||
if (DISABLE_SINGLE_INSTANCE_LOCK) {
|
if (DISABLE_SINGLE_INSTANCE_LOCK) {
|
||||||
log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK");
|
log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK");
|
||||||
@@ -575,15 +603,14 @@ function setupSingleInstanceLock(): boolean {
|
|||||||
isDefaultApp: false,
|
isDefaultApp: false,
|
||||||
});
|
});
|
||||||
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
|
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
|
||||||
const win = BrowserWindow.getAllWindows()[0];
|
// Relaunching the app (CLI `paseo [path]`, double-click, etc.) opens a new
|
||||||
if (win) {
|
// window rather than focusing the existing one. Wait for bootstrap (not just
|
||||||
win.show();
|
// app.whenReady) so the protocol + IPC handlers exist before the window loads.
|
||||||
if (win.isMinimized()) win.restore();
|
void bootstrapComplete
|
||||||
win.focus();
|
.then(() => createWindow({ pendingOpenProjectPath: openProjectPath }))
|
||||||
if (openProjectPath) {
|
.catch((error) => {
|
||||||
sendOpenProjectEvent(win, openProjectPath);
|
log.error("[window] failed to create window from second-instance", error);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -689,7 +716,13 @@ async function bootstrap(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
applyAppIcon();
|
applyAppIcon();
|
||||||
setupApplicationMenu();
|
setupApplicationMenu({
|
||||||
|
onNewWindow: () => {
|
||||||
|
void createWindow().catch((error) => {
|
||||||
|
log.error("[window] failed to create window from menu", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
ensureNotificationCenterRegistration();
|
ensureNotificationCenterRegistration();
|
||||||
if (await runDesktopSmokeIfRequested()) {
|
if (await runDesktopSmokeIfRequested()) {
|
||||||
return;
|
return;
|
||||||
@@ -701,11 +734,29 @@ async function bootstrap(): Promise<void> {
|
|||||||
registerOpenerHandlers();
|
registerOpenerHandlers();
|
||||||
registerEditorTargetHandlers();
|
registerEditorTargetHandlers();
|
||||||
|
|
||||||
await createMainWindow();
|
// In-app "Open in new window": opens a window that lands on the given project
|
||||||
|
// via the same open-project flow as a CLI launch (no move, no ownership).
|
||||||
|
ipcMain.handle("paseo:window:openNew", async (_event, options?: unknown) => {
|
||||||
|
const pendingPath =
|
||||||
|
options && typeof options === "object" && "pendingOpenProjectPath" in options
|
||||||
|
? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath
|
||||||
|
: null;
|
||||||
|
await createWindow({
|
||||||
|
pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The first window of the session restores and persists saved geometry.
|
||||||
|
await createWindow({ pendingOpenProjectPath, restoreWindowState: true });
|
||||||
|
pendingOpenProjectPath = null;
|
||||||
|
|
||||||
|
// Protocol + IPC handlers and the first window now exist: release any
|
||||||
|
// second-instance launches that arrived during cold start.
|
||||||
|
resolveBootstrapComplete();
|
||||||
|
|
||||||
app.on("activate", async () => {
|
app.on("activate", async () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
await createMainWindow();
|
await createWindow({ restoreWindowState: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { PendingOpenProjectStore } from "./pending-open-project-store";
|
||||||
|
|
||||||
|
describe("PendingOpenProjectStore", () => {
|
||||||
|
it("stores pending paths per window and consumes them independently", () => {
|
||||||
|
const store = new PendingOpenProjectStore();
|
||||||
|
|
||||||
|
store.set(101, "/tmp/project-a");
|
||||||
|
store.set(202, "/tmp/project-b");
|
||||||
|
|
||||||
|
expect(store.take(101)).toBe("/tmp/project-a");
|
||||||
|
expect(store.take(202)).toBe("/tmp/project-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a pending path after it is consumed", () => {
|
||||||
|
const store = new PendingOpenProjectStore();
|
||||||
|
|
||||||
|
store.set(101, "/tmp/project-a");
|
||||||
|
|
||||||
|
expect(store.take(101)).toBe("/tmp/project-a");
|
||||||
|
expect(store.take(101)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty and whitespace-only paths", () => {
|
||||||
|
const store = new PendingOpenProjectStore();
|
||||||
|
|
||||||
|
store.set(101, " ");
|
||||||
|
|
||||||
|
expect(store.take(101)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export class PendingOpenProjectStore {
|
||||||
|
private readonly pendingPathByWebContentsId = new Map<number, string>();
|
||||||
|
|
||||||
|
set(webContentsId: number, projectPath: string | null | undefined): void {
|
||||||
|
const normalizedPath = this.normalizeProjectPath(projectPath);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingPathByWebContentsId.set(webContentsId, normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
take(webContentsId: number): string | null {
|
||||||
|
const projectPath = this.pendingPathByWebContentsId.get(webContentsId) ?? null;
|
||||||
|
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(webContentsId: number): void {
|
||||||
|
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeProjectPath(projectPath: string | null | undefined): string | null {
|
||||||
|
if (typeof projectPath !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedPath = projectPath.trim();
|
||||||
|
return trimmedPath ? trimmedPath : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
|
openNew: (options?: { pendingOpenProjectPath?: string | null }) =>
|
||||||
|
ipcRenderer.invoke("paseo:window:openNew", options),
|
||||||
getCurrentWindow: () => ({
|
getCurrentWindow: () => ({
|
||||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||||
|
|||||||
@@ -240,17 +240,19 @@ export function registerWindowManager(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupWindowResizeEvents(win: BrowserWindow): void {
|
export function setupWindowResizeEvents(win: BrowserWindow): void {
|
||||||
win.on("resize", () => {
|
// A resize/fullscreen event can fire while the window is tearing down; sending
|
||||||
|
// to a destroyed webContents throws. Guard so multi-window close doesn't surface
|
||||||
|
// "Object has been destroyed" exceptions.
|
||||||
|
const notifyResized = () => {
|
||||||
|
if (win.isDestroyed() || win.webContents.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
win.webContents.send("paseo:window:resized", {});
|
win.webContents.send("paseo:window:resized", {});
|
||||||
});
|
};
|
||||||
|
|
||||||
win.on("enter-full-screen", () => {
|
win.on("resize", notifyResized);
|
||||||
win.webContents.send("paseo:window:resized", {});
|
win.on("enter-full-screen", notifyResized);
|
||||||
});
|
win.on("leave-full-screen", notifyResized);
|
||||||
|
|
||||||
win.on("leave-full-screen", () => {
|
|
||||||
win.webContents.send("paseo:window:resized", {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -835,7 +835,7 @@ describe("model merging", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("built-in Claude profile models append to runtime models", async () => {
|
test("built-in Claude profile models replace runtime models (issue #1299)", async () => {
|
||||||
mockState.runtimeModels.set("claude", [
|
mockState.runtimeModels.set("claude", [
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -872,11 +872,6 @@ describe("model merging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(models).toEqual([
|
expect(models).toEqual([
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "runtime-model",
|
|
||||||
label: "Runtime Model",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
id: "shared-model",
|
id: "shared-model",
|
||||||
@@ -1129,4 +1124,32 @@ describe("model merging", () => {
|
|||||||
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
|
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
|
||||||
expect(defaultModel?.id).toBe("profile-default");
|
expect(defaultModel?.id).toBe("profile-default");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => {
|
||||||
|
mockState.runtimeModels.set("claude", [
|
||||||
|
{ provider: "claude", id: "claude-opus-4-8", label: "Opus 4.8", isDefault: true },
|
||||||
|
{ provider: "claude", id: "claude-opus-4-7", label: "Opus 4.7" },
|
||||||
|
{ provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6" },
|
||||||
|
{ provider: "claude", id: "claude-haiku-4-5", label: "Haiku 4.5" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const registry = buildProviderRegistry(logger, {
|
||||||
|
providerOverrides: {
|
||||||
|
claude: {
|
||||||
|
models: [
|
||||||
|
{ id: "MiniMax-M2.7", label: "MiniMax-M2.7" },
|
||||||
|
{ id: "MiniMax-M3", label: "MiniMax-M3", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await registry.claude.fetchModels({
|
||||||
|
cwd: "/tmp/registry-models",
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(models.map((model) => model.id)).toEqual(["MiniMax-M2.7", "MiniMax-M3"]);
|
||||||
|
expect(models.find((model) => model.isDefault)?.id).toBe("MiniMax-M3");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -523,7 +523,7 @@ function buildResolvedBuiltinProviders(
|
|||||||
runtimeSettings: mergedRuntimeSettings,
|
runtimeSettings: mergedRuntimeSettings,
|
||||||
profileModels: override?.models ?? [],
|
profileModels: override?.models ?? [],
|
||||||
additionalModels: override?.additionalModels ?? [],
|
additionalModels: override?.additionalModels ?? [],
|
||||||
profileModelsAreAdditive: definition.id === "claude",
|
profileModelsAreAdditive: false,
|
||||||
enabled: override?.enabled !== false,
|
enabled: override?.enabled !== false,
|
||||||
derivedFromProviderId: null,
|
derivedFromProviderId: null,
|
||||||
createBaseClient: (logger) =>
|
createBaseClient: (logger) =>
|
||||||
|
|||||||
@@ -156,6 +156,90 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => {
|
||||||
|
// never-resolving isAvailable forces the timeout path
|
||||||
|
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
|
||||||
|
const manager = new ProviderSnapshotManager({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
refreshTimeoutMs: 1,
|
||||||
|
providerOverrides: {
|
||||||
|
claude: { enabled: false },
|
||||||
|
copilot: { enabled: false },
|
||||||
|
opencode: { enabled: false },
|
||||||
|
pi: { enabled: false },
|
||||||
|
},
|
||||||
|
extraClients: { codex: createExtraClient("codex", { isAvailable }) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const entry = await manager.getProvider({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
provider: "codex",
|
||||||
|
wait: true,
|
||||||
|
});
|
||||||
|
expect(entry.provider).toBe("codex");
|
||||||
|
expect(entry.status).toBe("error");
|
||||||
|
expect(entry.error).toMatch(/after 1ms/);
|
||||||
|
} finally {
|
||||||
|
manager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => {
|
||||||
|
vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1");
|
||||||
|
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
|
||||||
|
const manager = new ProviderSnapshotManager({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
providerOverrides: {
|
||||||
|
claude: { enabled: false },
|
||||||
|
copilot: { enabled: false },
|
||||||
|
opencode: { enabled: false },
|
||||||
|
pi: { enabled: false },
|
||||||
|
},
|
||||||
|
extraClients: { codex: createExtraClient("codex", { isAvailable }) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const entry = await manager.getProvider({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
provider: "codex",
|
||||||
|
wait: true,
|
||||||
|
});
|
||||||
|
expect(entry.status).toBe("error");
|
||||||
|
expect(entry.error).toMatch(/after 1ms/);
|
||||||
|
} finally {
|
||||||
|
manager.destroy();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => {
|
||||||
|
vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1");
|
||||||
|
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
|
||||||
|
const manager = new ProviderSnapshotManager({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
refreshTimeoutMs: 5,
|
||||||
|
providerOverrides: {
|
||||||
|
claude: { enabled: false },
|
||||||
|
copilot: { enabled: false },
|
||||||
|
opencode: { enabled: false },
|
||||||
|
pi: { enabled: false },
|
||||||
|
},
|
||||||
|
extraClients: { codex: createExtraClient("codex", { isAvailable }) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const entry = await manager.getProvider({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
provider: "codex",
|
||||||
|
wait: true,
|
||||||
|
});
|
||||||
|
expect(entry.status).toBe("error");
|
||||||
|
// explicit option (5) wins over env var (1)
|
||||||
|
expect(entry.error).toMatch(/after 5ms/);
|
||||||
|
} finally {
|
||||||
|
manager.destroy();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("listProviders returns an entry per registered provider", async () => {
|
test("listProviders returns an entry per registered provider", async () => {
|
||||||
const manager = new ProviderSnapshotManager({
|
const manager = new ProviderSnapshotManager({
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
|
|||||||
@@ -29,6 +29,25 @@ import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js
|
|||||||
import type { MutableDaemonConfig } from "../daemon-config-store.js";
|
import type { MutableDaemonConfig } from "../daemon-config-store.js";
|
||||||
|
|
||||||
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
|
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
|
||||||
|
const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS";
|
||||||
|
|
||||||
|
// Provider refresh probes can be slow on cold starts (e.g. Copilot's first
|
||||||
|
// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers).
|
||||||
|
// Allow operators to bump the ceiling via env var without rebuilding.
|
||||||
|
function resolveRefreshTimeoutMs(option: number | undefined): number {
|
||||||
|
if (typeof option === "number" && Number.isFinite(option) && option > 0) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR];
|
||||||
|
if (fromEnv) {
|
||||||
|
// Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate.
|
||||||
|
const parsed = Number(fromEnv);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_REFRESH_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void;
|
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void;
|
||||||
|
|
||||||
@@ -124,7 +143,7 @@ export class ProviderSnapshotManager {
|
|||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.providerOverrides = options.providerOverrides;
|
this.providerOverrides = options.providerOverrides;
|
||||||
this.baseProviderOverrides = options.providerOverrides;
|
this.baseProviderOverrides = options.providerOverrides;
|
||||||
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
|
this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs);
|
||||||
this.providerRegistry = this.buildRegistry();
|
this.providerRegistry = this.buildRegistry();
|
||||||
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
|
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,28 +397,37 @@ describe("ClaudeAgentClient.listModels", () => {
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
test("returns hardcoded claude models", async () => {
|
test("returns hardcoded claude models", async () => {
|
||||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-"));
|
||||||
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
try {
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
configDir: emptyConfigDir,
|
||||||
|
});
|
||||||
|
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
||||||
|
|
||||||
expect(models.map((m) => m.id)).toEqual([
|
expect(models.map((m) => m.id)).toEqual([
|
||||||
"claude-opus-4-8[1m]",
|
"claude-opus-4-8[1m]",
|
||||||
"claude-opus-4-8",
|
"claude-opus-4-8",
|
||||||
"claude-opus-4-7[1m]",
|
"claude-opus-4-7[1m]",
|
||||||
"claude-opus-4-7",
|
"claude-opus-4-7",
|
||||||
"claude-opus-4-6[1m]",
|
"claude-opus-4-6[1m]",
|
||||||
"claude-opus-4-6",
|
"claude-opus-4-6",
|
||||||
"claude-sonnet-4-6[1m]",
|
"claude-sonnet-4-6[1m]",
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
expect(model.provider).toBe("claude");
|
expect(model.provider).toBe("claude");
|
||||||
expect(model.label.length).toBeGreaterThan(0);
|
expect(model.label.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultModel = models.find((m) => m.isDefault);
|
||||||
|
expect(defaultModel?.id).toBe("claude-opus-4-8");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultModel = models.find((m) => m.isDefault);
|
|
||||||
expect(defaultModel?.id).toBe("claude-opus-4-8");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ interface ClaudeAgentClientOptions {
|
|||||||
runtimeSettings?: ProviderRuntimeSettings;
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
queryFactory?: ClaudeQueryFactory;
|
queryFactory?: ClaudeQueryFactory;
|
||||||
resolveBinary?: () => Promise<string>;
|
resolveBinary?: () => Promise<string>;
|
||||||
|
configDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaudeAgentSessionOptions {
|
interface ClaudeAgentSessionOptions {
|
||||||
@@ -1277,6 +1278,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly queryFactory?: ClaudeQueryFactory;
|
private readonly queryFactory?: ClaudeQueryFactory;
|
||||||
private readonly resolveBinary: () => Promise<string>;
|
private readonly resolveBinary: () => Promise<string>;
|
||||||
|
private readonly configDir?: string;
|
||||||
|
|
||||||
constructor(options: ClaudeAgentClientOptions) {
|
constructor(options: ClaudeAgentClientOptions) {
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
@@ -1284,6 +1286,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.queryFactory = options.queryFactory;
|
this.queryFactory = options.queryFactory;
|
||||||
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
|
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
|
||||||
|
this.configDir = options.configDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
@@ -1334,7 +1337,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
|
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
|
||||||
return await getClaudeModelsWithSettings(this.logger);
|
return await getClaudeModelsWithSettings(this.logger, this.configDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||||
|
|||||||
@@ -98,9 +98,12 @@ export function getClaudeModels(): AgentModelDefinition[] {
|
|||||||
return CLAUDE_MODELS.map((model) => ({ ...model }));
|
return CLAUDE_MODELS.map((model) => ({ ...model }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getClaudeModelsWithSettings(logger: Logger): Promise<AgentModelDefinition[]> {
|
export async function getClaudeModelsWithSettings(
|
||||||
|
logger: Logger,
|
||||||
|
configDir?: string,
|
||||||
|
): Promise<AgentModelDefinition[]> {
|
||||||
const hardcodedModels = getClaudeModels();
|
const hardcodedModels = getClaudeModels();
|
||||||
const settingsModels = await readClaudeSettingsModels(logger);
|
const settingsModels = await readClaudeSettingsModels(logger, configDir);
|
||||||
if (settingsModels.length === 0) {
|
if (settingsModels.length === 0) {
|
||||||
return hardcodedModels;
|
return hardcodedModels;
|
||||||
}
|
}
|
||||||
@@ -119,8 +122,11 @@ export async function getClaudeModelsWithSettings(logger: Logger): Promise<Agent
|
|||||||
return models;
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readClaudeSettingsModels(logger: Logger): Promise<AgentModelDefinition[]> {
|
async function readClaudeSettingsModels(
|
||||||
const settingsPath = path.join(resolveClaudeConfigDir(), "settings.json");
|
logger: Logger,
|
||||||
|
configDir?: string,
|
||||||
|
): Promise<AgentModelDefinition[]> {
|
||||||
|
const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json");
|
||||||
|
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -155,8 +161,8 @@ async function readClaudeSettingsModels(logger: Logger): Promise<AgentModelDefin
|
|||||||
return models;
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveClaudeConfigDir(): string {
|
function resolveClaudeConfigDir(configDir?: string): string {
|
||||||
return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
return configDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSettingsModel(
|
function addSettingsModel(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
hashDaemonPassword,
|
hashDaemonPassword,
|
||||||
isBearerTokenValidAsync,
|
isBearerTokenValidAsync,
|
||||||
isBearerTokenValid,
|
isBearerTokenValid,
|
||||||
|
shouldBypassBearerAuth,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
|
|
||||||
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
||||||
@@ -52,4 +53,16 @@ describe("daemon bearer validator", () => {
|
|||||||
expect(extractWsBearerToken(protocol)).toBe("secret.with.dots");
|
expect(extractWsBearerToken(protocol)).toBe("secret.with.dots");
|
||||||
expect(extractWsBearerToken("paseo.other.secret")).toBeNull();
|
expect(extractWsBearerToken("paseo.other.secret")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("bypasses bearer auth for preflight, liveness, and capability-token routes", () => {
|
||||||
|
// Preflight is always bypassed regardless of path.
|
||||||
|
expect(shouldBypassBearerAuth("OPTIONS", "/api/status")).toBe(true);
|
||||||
|
// Unauthenticated liveness probe.
|
||||||
|
expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true);
|
||||||
|
// Guarded by its own single-use download token, not the daemon password.
|
||||||
|
expect(shouldBypassBearerAuth("GET", "/api/files/download")).toBe(true);
|
||||||
|
// Everything else stays behind the daemon password.
|
||||||
|
expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false);
|
||||||
|
expect(shouldBypassBearerAuth("POST", "/api/files/upload")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,9 +118,25 @@ export function createRequireBearerMiddleware(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Routes that authenticate via their own capability and therefore must not be
|
||||||
|
// gated a second time behind the daemon password.
|
||||||
|
const BEARER_AUTH_BYPASS_PATHS = new Set([
|
||||||
|
// Unauthenticated liveness probe.
|
||||||
|
"/api/health",
|
||||||
|
// Guarded by a single-use download token (crypto-random UUID, 60s TTL,
|
||||||
|
// consumed on first use) that is only ever issued over the
|
||||||
|
// already-authenticated WebSocket. The token IS the capability for this
|
||||||
|
// route. Requiring the daemon password on top of it breaks browser and
|
||||||
|
// Electron downloads: those trigger the download via an anchor navigation,
|
||||||
|
// which cannot attach an `Authorization` header. The download endpoint still
|
||||||
|
// rejects requests without a valid token (400/403), so dropping the bearer
|
||||||
|
// here does not make the route unauthenticated.
|
||||||
|
"/api/files/download",
|
||||||
|
]);
|
||||||
|
|
||||||
export function shouldBypassBearerAuth(method: string, path: string): boolean {
|
export function shouldBypassBearerAuth(method: string, path: string): boolean {
|
||||||
if (method === "OPTIONS") {
|
if (method === "OPTIONS") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return path === "/api/health";
|
return BEARER_AUTH_BYPASS_PATHS.has(path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,18 +65,39 @@ describe("daemon bearer auth", () => {
|
|||||||
auth: { password: CORRECT_PASSWORD_HASH },
|
auth: { password: CORRECT_PASSWORD_HASH },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`);
|
const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
|
||||||
expect(missing.status).toBe(401);
|
expect(missing.status).toBe(401);
|
||||||
|
|
||||||
const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, {
|
const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, {
|
||||||
headers: { Authorization: "Bearer wrong-password" },
|
headers: { Authorization: "Bearer wrong-password" },
|
||||||
});
|
});
|
||||||
expect(wrong.status).toBe(401);
|
expect(wrong.status).toBe(401);
|
||||||
|
|
||||||
const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, {
|
const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, {
|
||||||
headers: { Authorization: "Bearer correct-password" },
|
headers: { Authorization: "Bearer correct-password" },
|
||||||
});
|
});
|
||||||
expect(correct.status).toBe(400);
|
expect(correct.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows file downloads with only a capability token when password is configured", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({
|
||||||
|
auth: { password: CORRECT_PASSWORD_HASH },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// No bearer at all: the route is reachable, but the download token store
|
||||||
|
// rejects the request because no token was supplied (400, not 401).
|
||||||
|
const missingToken = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`);
|
||||||
|
expect(missingToken.status).toBe(400);
|
||||||
|
|
||||||
|
// An invalid token is rejected by the token store (403, not 401) — proving
|
||||||
|
// the token, not the daemon password, is what guards this route.
|
||||||
|
const invalidToken = await fetch(
|
||||||
|
`http://127.0.0.1:${daemonHandle.port}/api/files/download?token=invalid-token`,
|
||||||
|
);
|
||||||
|
expect(invalidToken.status).toBe(403);
|
||||||
} finally {
|
} finally {
|
||||||
await daemonHandle.close();
|
await daemonHandle.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,37 @@ describe("searchHomeDirectories", () => {
|
|||||||
expect(exactIndex).toBeLessThan(prefixIndex);
|
expect(exactIndex).toBeLessThan(prefixIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let Python virtual environments crowd out top-level project matches", async () => {
|
||||||
|
const projectPath = path.join(homeDir, "django-po-merge");
|
||||||
|
mkdirSync(projectPath, { recursive: true });
|
||||||
|
const dependencyPaths = ["venv", "env", "virtualenv"].map((environmentDirectoryName) =>
|
||||||
|
path.join(
|
||||||
|
homeDir,
|
||||||
|
`${environmentDirectoryName}-project`,
|
||||||
|
environmentDirectoryName,
|
||||||
|
"Lib",
|
||||||
|
"site-packages",
|
||||||
|
"django",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const dependencyPath of dependencyPaths) {
|
||||||
|
mkdirSync(dependencyPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await searchHomeDirectories({
|
||||||
|
homeDir,
|
||||||
|
query: "~/django",
|
||||||
|
limit: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedResults = results.map((result) => realpathSync.native(result));
|
||||||
|
const projectIndex = resolvedResults.indexOf(realpathSync.native(projectPath));
|
||||||
|
expect(projectIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
for (const dependencyPath of dependencyPaths) {
|
||||||
|
expect(resolvedResults).not.toContain(realpathSync.native(dependencyPath));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("prioritizes partial matches that appear earlier in the path", async () => {
|
it("prioritizes partial matches that appear earlier in the path", async () => {
|
||||||
const earlierPath = path.join(homeDir, "farofoo");
|
const earlierPath = path.join(homeDir, "farofoo");
|
||||||
const laterPath = path.join(homeDir, "x", "y", "farofoo");
|
const laterPath = path.join(homeDir, "x", "y", "farofoo");
|
||||||
|
|||||||
@@ -78,8 +78,11 @@ const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
|||||||
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
||||||
const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER;
|
const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER;
|
||||||
const NO_WORKSPACE_MATCH_TIER = 5;
|
const NO_WORKSPACE_MATCH_TIER = 5;
|
||||||
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set([
|
const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
"venv",
|
||||||
|
"env",
|
||||||
|
"virtualenv",
|
||||||
"dist",
|
"dist",
|
||||||
"build",
|
"build",
|
||||||
"target",
|
"target",
|
||||||
@@ -824,7 +827,9 @@ async function listChildDirectories(input: {
|
|||||||
);
|
);
|
||||||
const candidates = dirents.filter(
|
const candidates = dirents.filter(
|
||||||
(dirent) =>
|
(dirent) =>
|
||||||
!isHiddenDirectoryName(dirent.name) && (dirent.isDirectory() || dirent.isSymbolicLink()),
|
!isHiddenDirectoryName(dirent.name) &&
|
||||||
|
!isIgnoredSuggestionDirectoryName(dirent.name) &&
|
||||||
|
(dirent.isDirectory() || dirent.isSymbolicLink()),
|
||||||
);
|
);
|
||||||
const resolved = await Promise.all(
|
const resolved = await Promise.all(
|
||||||
candidates.map(async (dirent) => {
|
candidates.map(async (dirent) => {
|
||||||
@@ -864,7 +869,7 @@ async function listWorkspaceChildEntries(input: {
|
|||||||
);
|
);
|
||||||
const candidates = dirents.filter(
|
const candidates = dirents.filter(
|
||||||
(dirent) =>
|
(dirent) =>
|
||||||
!isHiddenDirectoryName(dirent.name) && !isIgnoredWorkspaceDirectoryName(dirent.name),
|
!isHiddenDirectoryName(dirent.name) && !isIgnoredSuggestionDirectoryName(dirent.name),
|
||||||
);
|
);
|
||||||
const resolved = await Promise.all(
|
const resolved = await Promise.all(
|
||||||
candidates.map(async (dirent) => {
|
candidates.map(async (dirent) => {
|
||||||
@@ -955,8 +960,8 @@ function isHiddenDirectoryName(name: string): boolean {
|
|||||||
return name.startsWith(".");
|
return name.startsWith(".");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIgnoredWorkspaceDirectoryName(name: string): boolean {
|
function isIgnoredSuggestionDirectoryName(name: string): boolean {
|
||||||
return WORKSPACE_IGNORED_DIRECTORY_NAMES.has(name);
|
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user