Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 58d1767a64 Fix mobile sidebar reset after theme switch 2026-03-28 09:59:50 +07:00
Mohamed Boudra 0ab346e5a2 fix(desktop): restore windows daemon bootstrap logging 2026-03-27 22:48:27 +07:00
Mohamed Boudra a8c04c26b9 fix: hardcode paseo://app as allowed CORS origin in daemon bootstrap
The desktop app uses the paseo:// custom protocol scheme, but the
origin was only passed via PASEO_CORS_ORIGINS when the desktop started
the daemon itself. If the daemon was started by the CLI, the origin
was missing and the Electron renderer's WebSocket connection was
rejected.

Also removes file:// and null from allowed origins — any page loaded
via file:// could connect to the daemon, which is a security gap.
2026-03-27 21:40:02 +07:00
9 changed files with 170 additions and 60 deletions
@@ -4,6 +4,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles"; import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220; const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -31,55 +35,59 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0 // Right sidebar: closed = +windowWidth (off-screen right), open = 0
const translateX = useSharedValue(isOpen ? 0 : windowWidth); const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const backdropOpacity = useSharedValue(isOpen ? 1 : 0); const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false); const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined); const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes // Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen); const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => { useEffect(() => {
// Skip if this is initial render or if we're mid-gesture const didStateChange = shouldSyncSidebarAnimation({
if (prevIsOpen.current === isOpen) { previousIsOpen: prevIsOpen.current,
return; nextIsOpen: isOpen,
} previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current; const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen; prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it // Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) { if (isGesturing.value) {
return; return;
} }
if (isOpen) { const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
translateX.value = withTiming(0, {
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
backdropOpacity.value = withTiming(1, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
return;
} }
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [ }, [
isOpen, isOpen,
translateX, translateX,
backdropOpacity, backdropOpacity,
windowWidth, windowWidth,
isGesturing, isGesturing,
mobileView,
desktopFileExplorerOpen,
]); ]);
const animateToOpen = () => { const animateToOpen = () => {
@@ -12,6 +12,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles"; import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220; const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -38,46 +42,53 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state // Initialize based on current state
const translateX = useSharedValue(isOpen ? 0 : -windowWidth); const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const backdropOpacity = useSharedValue(isOpen ? 1 : 0); const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false); const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined); const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes // Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen); const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => { useEffect(() => {
// Skip if this is initial render or if we're mid-gesture const didStateChange = shouldSyncSidebarAnimation({
if (prevIsOpen.current === isOpen) { previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return; return;
} }
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it // Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) { if (isGesturing.value) {
return; return;
} }
if (isOpen) { const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
translateX.value = withTiming(0, {
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
backdropOpacity.value = withTiming(1, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
return;
} }
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = useCallback(() => { const animateToOpen = useCallback(() => {
+26 -11
View File
@@ -1162,19 +1162,34 @@ export class HostRuntimeStore {
} }
private async bootstrapDesktop(): Promise<void> { private async bootstrapDesktop(): Promise<void> {
try { let lastError: unknown = null;
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen); for (let attempt = 1; attempt <= 3; attempt += 1) {
if (!connection || !daemon.serverId) { try {
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection || !daemon.serverId) {
return;
}
await this.upsertHostConnection({
serverId: daemon.serverId,
label: daemon.hostname ?? undefined,
connection,
});
return; return;
} catch (error) {
lastError = error;
console.warn(`[HostRuntime] Failed to bootstrap desktop daemon (attempt ${attempt}/3)`, error);
if (attempt < 3) {
await new Promise((resolve) => {
setTimeout(resolve, attempt * 500);
});
}
} }
await this.upsertHostConnection({ }
serverId: daemon.serverId,
label: daemon.hostname ?? undefined, if (lastError) {
connection, console.warn("[HostRuntime] Desktop daemon bootstrap exhausted retries", lastError);
});
} catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon", error);
} }
} }
@@ -896,12 +896,6 @@ export default function SettingsScreen() {
const handleThemeChange = useCallback( const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => { (newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme }); void updateSettings({ theme: newTheme });
if (newTheme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(newTheme);
}
}, },
[updateSettings], [updateSettings],
); );
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
describe("sidebar-animation-state", () => {
it("requests a sync when the open state changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: true,
previousWindowWidth: 390,
nextWindowWidth: 390,
}),
).toBe(true);
});
it("requests a sync when the viewport width changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: false,
previousWindowWidth: 390,
nextWindowWidth: 430,
}),
).toBe(true);
});
it("keeps the left sidebar fully off-screen when closed", () => {
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: -430,
backdropOpacity: 0,
});
});
it("keeps the right sidebar fully off-screen when closed", () => {
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: 430,
backdropOpacity: 0,
});
});
});
@@ -0,0 +1,41 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen ||
input.previousWindowWidth !== input.nextWindowWidth
);
}
export function getLeftSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : -input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function getRightSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
@@ -148,8 +148,6 @@ function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
.filter((value) => value.length > 0), .filter((value) => value.length > 0),
); );
origins.add("paseo://app");
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL; const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
try { try {
const parsed = new URL(devServerUrl); const parsed = new URL(devServerUrl);
+1 -1
View File
@@ -1,5 +1,5 @@
import log from "electron-log/main"; import log from "electron-log/main";
log.initialize(); log.initialize({ spyRendererConsole: true });
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
+2 -3
View File
@@ -246,9 +246,8 @@ export async function createPaseoDaemon(
// CORS - allow same-origin + configured origins // CORS - allow same-origin + configured origins
const allowedOrigins = new Set([ const allowedOrigins = new Set([
...config.corsAllowedOrigins, ...config.corsAllowedOrigins,
// Packaged desktop renderers may send `file://` or `null` as the origin. // Packaged desktop renderers use the custom paseo:// protocol scheme.
"file://", "paseo://app",
"null",
// For TCP, add localhost variants // For TCP, add localhost variants
...(listenTarget.type === "tcp" ...(listenTarget.type === "tcp"
? [ ? [