refactor: migrate to VS Code titlebar drag region pattern
This commit is contained in:
@@ -68,7 +68,7 @@ import {
|
|||||||
ensureOsNotificationPermission,
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
@@ -476,6 +476,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||||
const systemColorScheme = useColorScheme();
|
const systemColorScheme = useColorScheme();
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||||
|
|
||||||
// Apply theme setting on mount and when it changes
|
// Apply theme setting on mount and when it changes
|
||||||
@@ -494,10 +495,13 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
|
void updateDesktopWindowControls({
|
||||||
console.warn("[DesktopWindow] Failed to update title bar theme", error);
|
backgroundColor: theme.colors.surface0,
|
||||||
|
foregroundColor: theme.colors.foreground,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||||
});
|
});
|
||||||
}, [settingsLoading, resolvedTheme]);
|
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
|
import { getDesktopWindow, updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
|
|
||||||
|
const DEFAULT_OVERLAY_HEIGHT = 29;
|
||||||
|
const MAX_EFFECTIVE_TITLEBAR_HEIGHT = 48;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VS Code-style titlebar drag region for Electron on Windows/Linux.
|
||||||
|
*
|
||||||
|
* Architecture copied from VS Code at commit daa0a70:
|
||||||
|
* - titlebarPart.ts:463-464 → dedicated absolute drag overlay element
|
||||||
|
* - titlebarpart.css:57-64 → drag region styles
|
||||||
|
* - titlebarpart.css:249-260 → top-edge resizer with no-drag, hidden in fullscreen
|
||||||
|
* - electron-browser/titlebarPart.ts:205-206 → resizer appended to root container
|
||||||
|
*
|
||||||
|
* This component renders two elements:
|
||||||
|
* 1. A full-size absolute drag overlay with -webkit-app-region: drag
|
||||||
|
* 2. A 4px top-edge resizer with -webkit-app-region: no-drag (hidden in fullscreen)
|
||||||
|
*
|
||||||
|
* Both are positioned absolute within the parent container. Interactive children
|
||||||
|
* of the parent sit above the drag overlay via normal stacking order.
|
||||||
|
* The global no-drag reset in index.html remains only as a defensive backstop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function useIsFullscreen(): boolean {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== "web" || !getIsElectronRuntime()) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const win = getDesktopWindow();
|
||||||
|
if (!win) return;
|
||||||
|
|
||||||
|
const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
|
||||||
|
if (disposed) return;
|
||||||
|
setIsFullscreen(fs);
|
||||||
|
|
||||||
|
if (typeof win.onResized !== "function") return;
|
||||||
|
|
||||||
|
const unlisten = await win.onResized(async () => {
|
||||||
|
if (disposed) return;
|
||||||
|
const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
|
||||||
|
if (disposed) return;
|
||||||
|
setIsFullscreen(fullscreen);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup = unlisten;
|
||||||
|
if (disposed) cleanup?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isFullscreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the VS Code-style drag overlay and top-edge resizer.
|
||||||
|
* Only renders on Electron Windows/Linux (non-mac). Returns null otherwise.
|
||||||
|
*
|
||||||
|
* Usage: Place as a child of any positioned container that should be draggable.
|
||||||
|
* Interactive elements in the same container will sit above the drag overlay
|
||||||
|
* via normal DOM stacking order (later siblings paint over earlier ones).
|
||||||
|
* Place TitlebarDragRegion BEFORE interactive children in JSX.
|
||||||
|
*/
|
||||||
|
export function TitlebarDragRegion() {
|
||||||
|
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TitlebarDragRegionInner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit no-drag content layer above the drag overlay.
|
||||||
|
* Matches VS Code's pattern of z-index: 2500 + -webkit-app-region: no-drag
|
||||||
|
* on interactive containers within the titlebar (titlebarpart.css:141-143,
|
||||||
|
* 231-233, 264-268, 288-298, 382-401).
|
||||||
|
*
|
||||||
|
* Wrap interactive children of any TitlebarDragRegion parent in this component
|
||||||
|
* so the no-drag layering is structural, not dependent on the global CSS backstop.
|
||||||
|
* On non-Electron or mac, renders children directly with no wrapper.
|
||||||
|
*/
|
||||||
|
interface TitlebarNoDragContentProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Layout direction for the no-drag wrapper. Default: "row" for header-style surfaces. */
|
||||||
|
direction?: "row" | "column";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TitlebarNoDragContent({ children, direction = "row" }: TitlebarNoDragContentProps) {
|
||||||
|
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 2500,
|
||||||
|
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||||
|
WebkitAppRegion: "no-drag",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: direction,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: direction === "column" ? "center" : undefined,
|
||||||
|
flex: 1,
|
||||||
|
height: "100%",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitlebarDragRegionInner() {
|
||||||
|
const isFullscreen = useIsFullscreen();
|
||||||
|
const dragRegionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const lastReportedHeightRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dragRegion = dragRegionRef.current;
|
||||||
|
const parent = dragRegion?.parentElement;
|
||||||
|
if (!dragRegion || !parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let frameId: number | null = null;
|
||||||
|
|
||||||
|
const reportHeight = () => {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHeight = Math.min(
|
||||||
|
MAX_EFFECTIVE_TITLEBAR_HEIGHT,
|
||||||
|
Math.max(DEFAULT_OVERLAY_HEIGHT, Math.round(parent.getBoundingClientRect().height)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastReportedHeightRef.current === nextHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReportedHeightRef.current = nextHeight;
|
||||||
|
void updateDesktopWindowControls({ height: nextHeight }).catch((error) => {
|
||||||
|
console.warn("[DesktopWindow] Failed to update window controls height", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReport = () => {
|
||||||
|
if (frameId !== null) {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
}
|
||||||
|
frameId = requestAnimationFrame(() => {
|
||||||
|
frameId = null;
|
||||||
|
reportHeight();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleReport();
|
||||||
|
|
||||||
|
const resizeObserver =
|
||||||
|
typeof ResizeObserver === "function"
|
||||||
|
? new ResizeObserver(() => {
|
||||||
|
scheduleReport();
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
resizeObserver?.observe(parent);
|
||||||
|
window.addEventListener("resize", scheduleReport);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (frameId !== null) {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
}
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
window.removeEventListener("resize", scheduleReport);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Drag overlay — equivalent to VS Code .titlebar-drag-region */}
|
||||||
|
<div
|
||||||
|
ref={dragRegionRef}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||||
|
WebkitAppRegion: "drag",
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Top-edge resizer — equivalent to VS Code .resizer */}
|
||||||
|
{!isFullscreen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: 4,
|
||||||
|
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||||
|
WebkitAppRegion: "no-drag",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
isCompactFormFactor,
|
isCompactFormFactor,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
|
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
|
||||||
interface ScreenHeaderProps {
|
interface ScreenHeaderProps {
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
@@ -31,8 +32,6 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
|||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const baseHorizontalPadding = theme.spacing[2];
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
|
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||||
@@ -45,10 +44,12 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
|||||||
},
|
},
|
||||||
borderless && styles.borderless,
|
borderless && styles.borderless,
|
||||||
]}
|
]}
|
||||||
{...dragHandlers}
|
|
||||||
>
|
>
|
||||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
<TitlebarDragRegion />
|
||||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
<TitlebarNoDragContent>
|
||||||
|
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||||
|
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||||
|
</TitlebarNoDragContent>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -61,6 +62,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
inner: {},
|
inner: {},
|
||||||
row: {
|
row: {
|
||||||
|
position: "relative",
|
||||||
height: {
|
height: {
|
||||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||||
md: HEADER_INNER_HEIGHT,
|
md: HEADER_INNER_HEIGHT,
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ import {
|
|||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
|
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
@@ -638,7 +639,6 @@ function DesktopSidebar({
|
|||||||
handleViewMore,
|
handleViewMore,
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const newAgentKeys = useShortcutKeys("new-agent");
|
const newAgentKeys = useShortcutKeys("new-agent");
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
|
||||||
const padding = useWindowControlsPadding("sidebar");
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
@@ -689,11 +689,14 @@ function DesktopSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||||
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
|
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
<View style={styles.sidebarHeader}>
|
||||||
<View style={styles.sidebarHeaderRow}>
|
<TitlebarDragRegion />
|
||||||
<SessionsButton onPress={handleViewMore} />
|
<TitlebarNoDragContent>
|
||||||
</View>
|
<View style={styles.sidebarHeaderRow}>
|
||||||
|
<SessionsButton onPress={handleViewMore} />
|
||||||
|
</View>
|
||||||
|
</TitlebarNoDragContent>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
@@ -832,6 +835,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
sidebarHeader: {
|
sidebarHeader: {
|
||||||
|
position: "relative",
|
||||||
height: {
|
height: {
|
||||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||||
md: HEADER_INNER_HEIGHT,
|
md: HEADER_INNER_HEIGHT,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
|
import {
|
||||||
|
getDesktopHost,
|
||||||
|
type DesktopWindowBridge,
|
||||||
|
type DesktopWindowControlsOverlayUpdate,
|
||||||
|
} from "@/desktop/host";
|
||||||
|
|
||||||
export function getDesktopWindow(): DesktopWindowBridge | null {
|
export function getDesktopWindow(): DesktopWindowBridge | null {
|
||||||
const getter = getDesktopHost()?.window?.getCurrentWindow;
|
const getter = getDesktopHost()?.window?.getCurrentWindow;
|
||||||
@@ -28,11 +32,13 @@ export async function isDesktopFullscreen(): Promise<boolean> {
|
|||||||
return await win.isFullscreen();
|
return await win.isFullscreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
|
export async function updateDesktopWindowControls(
|
||||||
|
update: DesktopWindowControlsOverlayUpdate,
|
||||||
|
): Promise<void> {
|
||||||
const win = getDesktopWindow();
|
const win = getDesktopWindow();
|
||||||
if (!win || typeof win.setTitleBarTheme !== "function") {
|
if (!win || typeof win.updateWindowControls !== "function") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await win.setTitleBarTheme(theme);
|
await win.updateWindowControls(update);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,14 +44,17 @@ export interface DesktopMenuBridge {
|
|||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopWindowControlsOverlayUpdate {
|
||||||
|
height?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
foregroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopWindowBridge {
|
export interface DesktopWindowBridge {
|
||||||
label?: string;
|
label?: string;
|
||||||
startMove?: (screenX: number, screenY: number) => void;
|
|
||||||
moving?: (screenX: number, screenY: number) => void;
|
|
||||||
endMove?: () => void;
|
|
||||||
toggleMaximize?: () => Promise<void>;
|
toggleMaximize?: () => Promise<void>;
|
||||||
isFullscreen?: () => Promise<boolean>;
|
isFullscreen?: () => Promise<boolean>;
|
||||||
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
|
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
|
||||||
onResized?: <TEvent = unknown>(
|
onResized?: <TEvent = unknown>(
|
||||||
handler: (event: TEvent) => void,
|
handler: (event: TEvent) => void,
|
||||||
) => Promise<() => void> | (() => void);
|
) => Promise<() => void> | (() => void);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
@@ -243,7 +243,6 @@ function DraftAgentScreenContent({
|
|||||||
const activateExplorerTabForCheckout = usePanelStore(
|
const activateExplorerTabForCheckout = usePanelStore(
|
||||||
(state) => state.activateExplorerTabForCheckout,
|
(state) => state.activateExplorerTabForCheckout,
|
||||||
);
|
);
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
|
||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
const draftIdRef = useRef(generateDraftId());
|
const draftIdRef = useRef(generateDraftId());
|
||||||
const draftAgentIdRef = useRef(generateDraftId());
|
const draftAgentIdRef = useRef(generateDraftId());
|
||||||
@@ -999,8 +998,10 @@ function DraftAgentScreenContent({
|
|||||||
const explorerServerId = draftExplorerCheckout?.serverId ?? null;
|
const explorerServerId = draftExplorerCheckout?.serverId ?? null;
|
||||||
const explorerIsGit = draftExplorerCheckout?.isGit ?? false;
|
const explorerIsGit = draftExplorerCheckout?.isGit ?? false;
|
||||||
const mainContent = (
|
const mainContent = (
|
||||||
<View style={styles.container} {...dragHandlers}>
|
<View style={styles.container}>
|
||||||
<View style={styles.outerContainer}>
|
<TitlebarDragRegion />
|
||||||
|
<TitlebarNoDragContent direction="column">
|
||||||
|
<View style={styles.outerContainer}>
|
||||||
<View style={styles.agentPanel}>
|
<View style={styles.agentPanel}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -1263,6 +1264,7 @@ function DraftAgentScreenContent({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
</TitlebarNoDragContent>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1292,6 +1294,7 @@ function DraftAgentScreenContent({
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
|
position: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor } from "@/constants/layout";
|
||||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
|
||||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||||
@@ -18,7 +18,6 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
||||||
|
|
||||||
const isCompactLayout = isCompactFormFactor();
|
const isCompactLayout = isCompactFormFactor();
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCompactLayout) {
|
if (!isCompactLayout) {
|
||||||
@@ -29,23 +28,26 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<MenuHeader borderless />
|
<MenuHeader borderless />
|
||||||
<View style={styles.content} {...dragHandlers}>
|
<View style={styles.content}>
|
||||||
<View style={styles.logo}>
|
<TitlebarDragRegion />
|
||||||
<PaseoLogo size={56} />
|
<TitlebarNoDragContent direction="column">
|
||||||
</View>
|
<View style={styles.logo}>
|
||||||
<View style={styles.headingGroup}>
|
<PaseoLogo size={56} />
|
||||||
<Text style={styles.heading}>What shall we build today?</Text>
|
</View>
|
||||||
{hasHydrated && !hasProjects ? (
|
<View style={styles.headingGroup}>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.heading}>What shall we build today?</Text>
|
||||||
Add a project folder to start running agents on your codebase
|
{hasHydrated && !hasProjects ? (
|
||||||
</Text>
|
<Text style={styles.subtitle}>
|
||||||
) : null}
|
Add a project folder to start running agents on your codebase
|
||||||
</View>
|
</Text>
|
||||||
<View style={styles.cta}>
|
) : null}
|
||||||
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
|
</View>
|
||||||
Add a project
|
<View style={styles.cta}>
|
||||||
</Button>
|
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
|
||||||
</View>
|
Add a project
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</TitlebarNoDragContent>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -58,6 +60,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
|
position: "relative",
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getDesktopDaemonLogs,
|
getDesktopDaemonLogs,
|
||||||
type DesktopDaemonLogs,
|
type DesktopDaemonLogs,
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
|
||||||
type StartupSplashScreenProps = {
|
type StartupSplashScreenProps = {
|
||||||
bootstrapState?: {
|
bootstrapState?: {
|
||||||
@@ -26,6 +26,7 @@ const DOCS_URL = "https://paseo.sh/docs";
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
|
position: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -140,7 +141,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
|
|
||||||
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
|
||||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||||
const [logsError, setLogsError] = useState<string | null>(null);
|
const [logsError, setLogsError] = useState<string | null>(null);
|
||||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||||
@@ -222,39 +222,47 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
|||||||
|
|
||||||
if (isSimpleSplash) {
|
if (isSimpleSplash) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} {...dragHandlers}>
|
<View style={styles.container}>
|
||||||
<PaseoLogo size={96} />
|
<TitlebarDragRegion />
|
||||||
<Text style={styles.subtitle}>Starting up…</Text>
|
<TitlebarNoDragContent direction="column">
|
||||||
|
<PaseoLogo size={96} />
|
||||||
|
<Text style={styles.subtitle}>Starting up…</Text>
|
||||||
|
</TitlebarNoDragContent>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isError) {
|
if (!isError) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} {...dragHandlers}>
|
<View style={styles.container}>
|
||||||
<View style={styles.centeredContent}>
|
<TitlebarDragRegion />
|
||||||
<PaseoLogo size={96} />
|
<TitlebarNoDragContent direction="column">
|
||||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
<View style={styles.centeredContent}>
|
||||||
<View style={styles.progressSteps}>
|
<PaseoLogo size={96} />
|
||||||
{progressSteps.map((step) => (
|
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||||
<View key={step.key} style={styles.progressStepRow}>
|
<View style={styles.progressSteps}>
|
||||||
{step.status === "complete" ? (
|
{progressSteps.map((step) => (
|
||||||
<Check size={18} color={theme.colors.success} />
|
<View key={step.key} style={styles.progressStepRow}>
|
||||||
) : (
|
{step.status === "complete" ? (
|
||||||
<ActivityIndicator color={theme.colors.accent} />
|
<Check size={18} color={theme.colors.success} />
|
||||||
)}
|
) : (
|
||||||
<Text style={styles.subtitleInline}>{step.label}</Text>
|
<ActivityIndicator color={theme.colors.accent} />
|
||||||
</View>
|
)}
|
||||||
))}
|
<Text style={styles.subtitleInline}>{step.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TitlebarNoDragContent>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
|
<View style={[styles.container, styles.containerError]}>
|
||||||
<View style={styles.errorContent}>
|
<TitlebarDragRegion />
|
||||||
|
<TitlebarNoDragContent direction="column">
|
||||||
|
<View style={styles.errorContent}>
|
||||||
<View style={styles.errorHeader}>
|
<View style={styles.errorHeader}>
|
||||||
<PaseoLogo size={64} />
|
<PaseoLogo size={64} />
|
||||||
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
||||||
@@ -313,6 +321,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</TitlebarNoDragContent>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
|
||||||
|
|
||||||
describe("readFiniteScreenPoint", () => {
|
|
||||||
it("returns finite screen coordinates", () => {
|
|
||||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
|
|
||||||
screenX: 1280,
|
|
||||||
screenY: 720,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects NaN screen coordinates", () => {
|
|
||||||
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull();
|
|
||||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects infinite screen coordinates", () => {
|
|
||||||
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull();
|
|
||||||
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects missing screen coordinates", () => {
|
|
||||||
expect(readFiniteScreenPoint(undefined)).toBeNull();
|
|
||||||
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull();
|
|
||||||
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export type DesktopWindowScreenPoint = {
|
|
||||||
screenX: number;
|
|
||||||
screenY: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenPointInput =
|
|
||||||
| {
|
|
||||||
screenX?: unknown;
|
|
||||||
screenY?: unknown;
|
|
||||||
}
|
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
function isFiniteCoordinate(value: unknown): value is number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
|
|
||||||
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
screenX: input.screenX,
|
|
||||||
screenY: input.screenY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { isInteractiveDesktopDragTarget } from "./desktop-window";
|
|
||||||
|
|
||||||
function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget {
|
|
||||||
return {
|
|
||||||
closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null),
|
|
||||||
} as unknown as EventTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("isInteractiveDesktopDragTarget", () => {
|
|
||||||
it("treats focusable pressables as interactive drag exemptions", () => {
|
|
||||||
const target = createTarget({
|
|
||||||
matchesSelector: (selector) => selector.includes("[tabindex]"),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats semantic button targets as interactive drag exemptions", () => {
|
|
||||||
const target = createTarget({
|
|
||||||
matchesSelector: (selector) => selector.includes("[role='button']"),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for non-interactive targets", () => {
|
|
||||||
const target = createTarget({
|
|
||||||
matchesSelector: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(isInteractiveDesktopDragTarget(target)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when the target does not support closest()", () => {
|
|
||||||
expect(isInteractiveDesktopDragTarget(null)).toBe(false);
|
|
||||||
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
getIsElectronRuntimeMac,
|
getIsElectronRuntimeMac,
|
||||||
getIsElectronRuntime,
|
getIsElectronRuntime,
|
||||||
@@ -9,120 +9,7 @@ import {
|
|||||||
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
|
||||||
|
|
||||||
export async function toggleMaximize() {
|
|
||||||
const win = getDesktopWindow();
|
|
||||||
if (win && typeof win.toggleMaximize === "function") {
|
|
||||||
try {
|
|
||||||
await win.toggleMaximize();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[DesktopWindow] toggleMaximize failed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Manual window dragging via pointer events.
|
|
||||||
// Mirrors the Tauri implementation: single pointerdown handler with
|
|
||||||
// double-click-to-maximize via timing, closest() for interactive check,
|
|
||||||
// and pointer capture for move tracking.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const INTERACTIVE_SELECTOR =
|
|
||||||
"button, a, input, textarea, select, " +
|
|
||||||
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
|
|
||||||
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
|
|
||||||
"[role='menuitem'], [tabindex], [contenteditable='true']";
|
|
||||||
|
|
||||||
const DOUBLE_CLICK_MS = 300;
|
|
||||||
|
|
||||||
type DesktopDragViewProps = Pick<
|
|
||||||
ViewProps,
|
|
||||||
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function isInteractiveDesktopDragTarget(target: unknown): boolean {
|
|
||||||
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
|
|
||||||
if (!candidate || typeof candidate.closest !== "function") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Boolean(candidate.closest(INTERACTIVE_SELECTOR));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDesktopDragHandlers(): DesktopDragViewProps {
|
|
||||||
const isDragging = useRef(false);
|
|
||||||
const lastPointerDownAt = useRef(0);
|
|
||||||
const isActive = Platform.OS === "web" && isElectronRuntime();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isActive) return;
|
|
||||||
function handleBlur() {
|
|
||||||
if (!isDragging.current) return;
|
|
||||||
isDragging.current = false;
|
|
||||||
getDesktopWindow()?.endMove?.();
|
|
||||||
}
|
|
||||||
window.addEventListener("blur", handleBlur);
|
|
||||||
return () => window.removeEventListener("blur", handleBlur);
|
|
||||||
}, [isActive]);
|
|
||||||
|
|
||||||
return useMemo((): DesktopDragViewProps => {
|
|
||||||
if (!isActive) return {};
|
|
||||||
|
|
||||||
function stopDrag(e: RNPointerEvent) {
|
|
||||||
if (!isDragging.current) return;
|
|
||||||
isDragging.current = false;
|
|
||||||
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
|
|
||||||
const el = e.currentTarget as unknown as Element | null;
|
|
||||||
if (el && "releasePointerCapture" in el) {
|
|
||||||
el.releasePointerCapture(e.nativeEvent.pointerId);
|
|
||||||
}
|
|
||||||
getDesktopWindow()?.endMove?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
onPointerDown: (e: RNPointerEvent) => {
|
|
||||||
if (e.nativeEvent.button !== 0) return;
|
|
||||||
|
|
||||||
if (isInteractiveDesktopDragTarget(e.target)) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
|
|
||||||
lastPointerDownAt.current = 0;
|
|
||||||
void toggleMaximize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastPointerDownAt.current = now;
|
|
||||||
|
|
||||||
const win = getDesktopWindow();
|
|
||||||
if (!win?.startMove) return;
|
|
||||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
|
|
||||||
if (!screenPoint) return;
|
|
||||||
|
|
||||||
isDragging.current = true;
|
|
||||||
const el = e.currentTarget as unknown as Element;
|
|
||||||
el.setPointerCapture(e.nativeEvent.pointerId);
|
|
||||||
win.startMove(screenPoint.screenX, screenPoint.screenY);
|
|
||||||
},
|
|
||||||
onPointerMove: (e: RNPointerEvent) => {
|
|
||||||
if (!isDragging.current) return;
|
|
||||||
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
|
|
||||||
if (!screenPoint) {
|
|
||||||
stopDrag(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY);
|
|
||||||
},
|
|
||||||
onPointerUp: stopDrag,
|
|
||||||
onPointerCancel: stopDrag,
|
|
||||||
};
|
|
||||||
}, [isActive]);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RawWindowControlsPadding = {
|
type RawWindowControlsPadding = {
|
||||||
left: number;
|
left: number;
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
|
|||||||
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
|
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
|
||||||
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
|
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
|
||||||
const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"];
|
const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"];
|
||||||
|
const INTERACTIVE_SELECTOR = [
|
||||||
|
"button",
|
||||||
|
"a",
|
||||||
|
"input",
|
||||||
|
"textarea",
|
||||||
|
"select",
|
||||||
|
"[role='button']",
|
||||||
|
"[role='link']",
|
||||||
|
"[role='textbox']",
|
||||||
|
"[role='combobox']",
|
||||||
|
"[role='tab']",
|
||||||
|
"[role='switch']",
|
||||||
|
"[role='checkbox']",
|
||||||
|
"[role='slider']",
|
||||||
|
"[role='menuitem']",
|
||||||
|
"[tabindex]",
|
||||||
|
"[contenteditable='true']",
|
||||||
|
].join(", ");
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
@@ -28,6 +46,313 @@ async function captureScreenshot(page, fileName) {
|
|||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function inspectTitlebarRegions(page) {
|
||||||
|
return page.evaluate((interactiveSelector) => {
|
||||||
|
const nodes = Array.from(document.querySelectorAll("*"));
|
||||||
|
const annotationId = "electron-verify-titlebar-style";
|
||||||
|
const existingAnnotation = document.getElementById(annotationId);
|
||||||
|
existingAnnotation?.remove();
|
||||||
|
|
||||||
|
const annotationStyle = document.createElement("style");
|
||||||
|
annotationStyle.id = annotationId;
|
||||||
|
annotationStyle.textContent = `
|
||||||
|
[data-electron-verify-drag="true"] {
|
||||||
|
outline: 3px solid #ff4d4f !important;
|
||||||
|
outline-offset: -3px !important;
|
||||||
|
}
|
||||||
|
[data-electron-verify-resizer="true"] {
|
||||||
|
outline: 3px solid #52c41a !important;
|
||||||
|
outline-offset: -3px !important;
|
||||||
|
}
|
||||||
|
[data-electron-verify-interactive="true"] {
|
||||||
|
outline: 3px solid #1677ff !important;
|
||||||
|
outline-offset: -3px !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(annotationStyle);
|
||||||
|
|
||||||
|
function isVisible(element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return (
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
style.display !== "none" &&
|
||||||
|
style.visibility !== "hidden" &&
|
||||||
|
style.opacity !== "0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeText(element) {
|
||||||
|
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAppRegion(element) {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectInfo(element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeElement(element) {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
tagName: element.tagName.toLowerCase(),
|
||||||
|
text: summarizeText(element),
|
||||||
|
appRegion: readAppRegion(element),
|
||||||
|
position: style.position,
|
||||||
|
zIndex: style.zIndex,
|
||||||
|
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
|
||||||
|
paddingTop: Number.parseFloat(style.paddingTop || "0"),
|
||||||
|
...rectInfo(element),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearTop(summary) {
|
||||||
|
return summary.top < 220;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTopResizer(element, overlayRect) {
|
||||||
|
if (!(element instanceof HTMLElement) || !isVisible(element)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const summary = summarizeElement(element);
|
||||||
|
return (
|
||||||
|
summary.appRegion === "no-drag" &&
|
||||||
|
summary.position === "absolute" &&
|
||||||
|
Math.abs(summary.height - 4) <= 1 &&
|
||||||
|
Math.abs(summary.top - overlayRect.top) <= 2 &&
|
||||||
|
Math.abs(summary.left - overlayRect.left) <= 2 &&
|
||||||
|
Math.abs(summary.width - overlayRect.width) <= 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeInteractive(element) {
|
||||||
|
const summary = summarizeElement(element);
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
testId: element.getAttribute("data-testid"),
|
||||||
|
role: element.getAttribute("role"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragSummaries = [];
|
||||||
|
const suspiciousDragHosts = [];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const summary = summarizeElement(node);
|
||||||
|
if (summary.appRegion !== "drag") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = node.parentElement instanceof HTMLElement ? node.parentElement : null;
|
||||||
|
const parentSummary = parent ? summarizeElement(parent) : null;
|
||||||
|
const interactiveDescendants = Array.from(node.querySelectorAll(interactiveSelector))
|
||||||
|
.filter((child) => child instanceof HTMLElement)
|
||||||
|
.filter((child) => isVisible(child))
|
||||||
|
.map((child) => summarizeInteractive(child));
|
||||||
|
|
||||||
|
const siblingResizers = parent
|
||||||
|
? Array.from(parent.children)
|
||||||
|
.filter((child) => child !== node)
|
||||||
|
.filter((child) => child instanceof HTMLElement)
|
||||||
|
.filter((child) => isTopResizer(child, summary))
|
||||||
|
.map((child) => summarizeElement(child))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const parentInteractive = parent
|
||||||
|
? Array.from(parent.querySelectorAll(interactiveSelector))
|
||||||
|
.filter((child) => child instanceof HTMLElement)
|
||||||
|
.filter((child) => isVisible(child))
|
||||||
|
.map((child) => summarizeInteractive(child))
|
||||||
|
: [];
|
||||||
|
const explicitNoDragInteractive = parentInteractive.filter(
|
||||||
|
(child) => child.appRegion === "no-drag",
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
...summary,
|
||||||
|
parent: parentSummary,
|
||||||
|
interactiveDescendants: interactiveDescendants.slice(0, 5),
|
||||||
|
siblingResizers: siblingResizers.slice(0, 3),
|
||||||
|
explicitNoDragInteractive: explicitNoDragInteractive.slice(0, 5),
|
||||||
|
parentInteractiveCount: parentInteractive.length,
|
||||||
|
};
|
||||||
|
dragSummaries.push(record);
|
||||||
|
|
||||||
|
const looksLikeHostShortcut =
|
||||||
|
isNearTop(summary) &&
|
||||||
|
(summary.position !== "absolute" ||
|
||||||
|
summary.text.length > 0 ||
|
||||||
|
interactiveDescendants.length > 0 ||
|
||||||
|
parentSummary?.appRegion === "drag");
|
||||||
|
if (looksLikeHostShortcut) {
|
||||||
|
suspiciousDragHosts.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifiedRegions = dragSummaries
|
||||||
|
.filter((entry) => isNearTop(entry))
|
||||||
|
.filter((entry) => entry.position === "absolute")
|
||||||
|
.filter((entry) => entry.text.length === 0)
|
||||||
|
.filter((entry) => entry.parent?.appRegion !== "drag")
|
||||||
|
.filter((entry) => entry.siblingResizers.length > 0)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.explicitNoDragInteractive.length - left.explicitNoDragInteractive.length ||
|
||||||
|
left.top - right.top ||
|
||||||
|
right.width - left.width,
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidate = verifiedRegions[0] ?? null;
|
||||||
|
if (candidate) {
|
||||||
|
const matchingDragNode = nodes.find((node) => {
|
||||||
|
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const summary = summarizeElement(node);
|
||||||
|
return (
|
||||||
|
summary.appRegion === "drag" &&
|
||||||
|
Math.abs(summary.top - candidate.top) <= 1 &&
|
||||||
|
Math.abs(summary.left - candidate.left) <= 1 &&
|
||||||
|
Math.abs(summary.width - candidate.width) <= 1 &&
|
||||||
|
Math.abs(summary.height - candidate.height) <= 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (matchingDragNode instanceof HTMLElement) {
|
||||||
|
matchingDragNode.setAttribute("data-electron-verify-drag", "true");
|
||||||
|
const parent = matchingDragNode.parentElement;
|
||||||
|
if (parent instanceof HTMLElement) {
|
||||||
|
for (const child of parent.children) {
|
||||||
|
if (child instanceof HTMLElement && isTopResizer(child, candidate)) {
|
||||||
|
child.setAttribute("data-electron-verify-resizer", "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const interactiveChildren = Array.from(parent.querySelectorAll(interactiveSelector))
|
||||||
|
.filter((child) => child instanceof HTMLElement)
|
||||||
|
.filter((child) => isVisible(child))
|
||||||
|
.filter((child) => summarizeElement(child).appRegion === "no-drag")
|
||||||
|
.slice(0, 3);
|
||||||
|
for (const child of interactiveChildren) {
|
||||||
|
child.setAttribute("data-electron-verify-interactive", "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
interactiveSelector,
|
||||||
|
dragRegionCount: dragSummaries.length,
|
||||||
|
verifiedRegionCount: verifiedRegions.length,
|
||||||
|
candidate,
|
||||||
|
suspiciousDragHosts: suspiciousDragHosts.slice(0, 10),
|
||||||
|
dragRegions: dragSummaries.slice(0, 10),
|
||||||
|
};
|
||||||
|
}, INTERACTIVE_SELECTOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectFullscreenResizer(page) {
|
||||||
|
const session = await page.context().newCDPSession(page);
|
||||||
|
let windowId = null;
|
||||||
|
let initialBounds = null;
|
||||||
|
let fullscreenEntered = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const windowInfo = await session.send("Browser.getWindowForTarget");
|
||||||
|
windowId = windowInfo.windowId;
|
||||||
|
initialBounds = await session.send("Browser.getWindowBounds", { windowId });
|
||||||
|
await session.send("Browser.setWindowBounds", {
|
||||||
|
windowId,
|
||||||
|
bounds: { windowState: "fullscreen" },
|
||||||
|
});
|
||||||
|
fullscreenEntered = true;
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const details = await page.evaluate(async () => {
|
||||||
|
const bridge = window.paseoDesktop?.window;
|
||||||
|
const bridgeFullscreen =
|
||||||
|
typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null;
|
||||||
|
const visibleNoDragResizers = Array.from(document.querySelectorAll("*"))
|
||||||
|
.filter((node) => node instanceof HTMLElement)
|
||||||
|
.filter((node) => {
|
||||||
|
const element = node;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
const appRegion =
|
||||||
|
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
|
||||||
|
return (
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
style.display !== "none" &&
|
||||||
|
style.visibility !== "hidden" &&
|
||||||
|
style.opacity !== "0" &&
|
||||||
|
appRegion === "no-drag" &&
|
||||||
|
style.position === "absolute" &&
|
||||||
|
Math.abs(rect.height - 4) <= 1 &&
|
||||||
|
rect.top < 220
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((node) => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
tagName: node.tagName.toLowerCase(),
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bridgeFullscreen,
|
||||||
|
visibleNoDragResizers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
enteredFullscreen: fullscreenEntered,
|
||||||
|
initialBounds,
|
||||||
|
...details,
|
||||||
|
passed:
|
||||||
|
details.bridgeFullscreen === true &&
|
||||||
|
Array.isArray(details.visibleNoDragResizers) &&
|
||||||
|
details.visibleNoDragResizers.length === 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
error: String(error),
|
||||||
|
initialBounds,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
const previousWindowState = initialBounds?.bounds?.windowState ?? "normal";
|
||||||
|
if (windowId !== null && fullscreenEntered) {
|
||||||
|
try {
|
||||||
|
await session.send("Browser.setWindowBounds", {
|
||||||
|
windowId,
|
||||||
|
bounds: { windowState: previousWindowState },
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
} catch {
|
||||||
|
// Best-effort restore only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await session.detach().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function findAppPage(browser) {
|
async function findAppPage(browser) {
|
||||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||||
for (const context of browser.contexts()) {
|
for (const context of browser.contexts()) {
|
||||||
@@ -132,115 +457,20 @@ async function main() {
|
|||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dragRegionCheck = await page.evaluate(() => {
|
const dragRegionCheck = await inspectTitlebarRegions(page);
|
||||||
const nodes = Array.from(document.querySelectorAll("*"));
|
|
||||||
const annotationId = "electron-verify-drag-style";
|
|
||||||
const existingAnnotation = document.getElementById(annotationId);
|
|
||||||
existingAnnotation?.remove();
|
|
||||||
|
|
||||||
const annotationStyle = document.createElement("style");
|
|
||||||
annotationStyle.id = annotationId;
|
|
||||||
annotationStyle.textContent = `
|
|
||||||
[data-electron-verify-drag="true"] {
|
|
||||||
outline: 3px solid #ff4d4f !important;
|
|
||||||
outline-offset: -3px !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(annotationStyle);
|
|
||||||
|
|
||||||
function isVisible(element) {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const style = window.getComputedStyle(element);
|
|
||||||
return (
|
|
||||||
rect.width > 0 &&
|
|
||||||
rect.height > 0 &&
|
|
||||||
style.display !== "none" &&
|
|
||||||
style.visibility !== "hidden" &&
|
|
||||||
style.opacity !== "0"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeText(element) {
|
|
||||||
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
const regions = [];
|
|
||||||
let candidate = null;
|
|
||||||
|
|
||||||
for (const element of nodes) {
|
|
||||||
if (!(element instanceof HTMLElement)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const style = window.getComputedStyle(element);
|
|
||||||
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
|
||||||
if (appRegion !== "drag" || !isVisible(element)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const text = summarizeText(element);
|
|
||||||
const info = {
|
|
||||||
tagName: element.tagName.toLowerCase(),
|
|
||||||
text,
|
|
||||||
top: rect.top,
|
|
||||||
left: rect.left,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
|
|
||||||
paddingTop: Number.parseFloat(style.paddingTop || "0"),
|
|
||||||
};
|
|
||||||
regions.push(info);
|
|
||||||
|
|
||||||
const looksLikeHeader =
|
|
||||||
rect.top < 180 &&
|
|
||||||
rect.height >= 40 &&
|
|
||||||
(text.includes("Settings") || text.includes("Sessions"));
|
|
||||||
if (!candidate && looksLikeHeader) {
|
|
||||||
candidate = { ...info };
|
|
||||||
element.setAttribute("data-electron-verify-drag", "true");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!candidate && regions.length > 0) {
|
|
||||||
candidate = regions.slice().sort((left, right) => left.top - right.top)[0];
|
|
||||||
|
|
||||||
for (const element of nodes) {
|
|
||||||
if (!(element instanceof HTMLElement)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const style = window.getComputedStyle(element);
|
|
||||||
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
|
||||||
if (appRegion !== "drag" || !isVisible(element)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
if (
|
|
||||||
Math.abs(rect.top - candidate.top) < 1 &&
|
|
||||||
Math.abs(rect.left - candidate.left) < 1 &&
|
|
||||||
Math.abs(rect.width - candidate.width) < 1 &&
|
|
||||||
Math.abs(rect.height - candidate.height) < 1
|
|
||||||
) {
|
|
||||||
element.setAttribute("data-electron-verify-drag", "true");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
count: regions.length,
|
|
||||||
candidate,
|
|
||||||
regions: regions.slice(0, 10),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
|
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
|
||||||
const dragRegionPassed =
|
const dragRegionPassed =
|
||||||
dragRegionCheck.count > 0 &&
|
dragRegionCheck.dragRegionCount > 0 &&
|
||||||
|
dragRegionCheck.verifiedRegionCount > 0 &&
|
||||||
Boolean(dragRegionCheck.candidate) &&
|
Boolean(dragRegionCheck.candidate) &&
|
||||||
dragRegionCheck.candidate.top < 180;
|
dragRegionCheck.candidate.top < 220 &&
|
||||||
|
dragRegionCheck.candidate.parent?.appRegion !== "drag" &&
|
||||||
|
dragRegionCheck.candidate.siblingResizers.length > 0 &&
|
||||||
|
dragRegionCheck.suspiciousDragHosts.length === 0;
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
check: "drag-regions",
|
check: "titlebar-drag-structure",
|
||||||
pass: dragRegionPassed,
|
pass: dragRegionPassed,
|
||||||
details: dragRegionCheck,
|
details: dragRegionCheck,
|
||||||
screenshot: dragScreenshot,
|
screenshot: dragScreenshot,
|
||||||
@@ -248,7 +478,7 @@ async function main() {
|
|||||||
|
|
||||||
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
|
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
|
const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null;
|
||||||
const trafficLightPaddingPassed = !isMac
|
const trafficLightPaddingPassed = !isMac
|
||||||
? true
|
? true
|
||||||
: typeof observedPaddingLeft === "number" &&
|
: typeof observedPaddingLeft === "number" &&
|
||||||
@@ -261,12 +491,36 @@ async function main() {
|
|||||||
details: {
|
details: {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
observedPaddingLeft,
|
observedPaddingLeft,
|
||||||
expectedApproximatePaddingLeft: 78,
|
note: "Traffic-light padding is only validated structurally on macOS in this verifier.",
|
||||||
candidate: dragRegionCheck.candidate,
|
candidate: dragRegionCheck.candidate,
|
||||||
},
|
},
|
||||||
screenshot: trafficLightScreenshot,
|
screenshot: trafficLightScreenshot,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const noDragInteractiveCheck = {
|
||||||
|
check: "interactive-no-drag-layering",
|
||||||
|
pass:
|
||||||
|
Boolean(dragRegionCheck.candidate) &&
|
||||||
|
Array.isArray(dragRegionCheck.candidate.explicitNoDragInteractive) &&
|
||||||
|
dragRegionCheck.candidate.explicitNoDragInteractive.length > 0,
|
||||||
|
details: {
|
||||||
|
candidate: dragRegionCheck.candidate,
|
||||||
|
explicitNoDragInteractive:
|
||||||
|
dragRegionCheck.candidate?.explicitNoDragInteractive ?? [],
|
||||||
|
},
|
||||||
|
screenshot: dragScreenshot,
|
||||||
|
};
|
||||||
|
results.push(noDragInteractiveCheck);
|
||||||
|
|
||||||
|
const fullscreenDetails = await inspectFullscreenResizer(page);
|
||||||
|
const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png");
|
||||||
|
results.push({
|
||||||
|
check: "fullscreen-resizer",
|
||||||
|
pass: fullscreenDetails.supported ? fullscreenDetails.passed : true,
|
||||||
|
details: fullscreenDetails,
|
||||||
|
screenshot: fullscreenScreenshot,
|
||||||
|
});
|
||||||
|
|
||||||
const daemonManagementVisible = await Promise.all([
|
const daemonManagementVisible = await Promise.all([
|
||||||
page.getByText("Built-in daemon", { exact: true }).isVisible(),
|
page.getByText("Built-in daemon", { exact: true }).isVisible(),
|
||||||
page.getByText("Daemon management", { exact: true }).isVisible(),
|
page.getByText("Daemon management", { exact: true }).isVisible(),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||||
import {
|
import {
|
||||||
registerWindowManager,
|
registerWindowManager,
|
||||||
getTitleBarOverlayOptions,
|
getMainWindowChromeOptions,
|
||||||
resolveSystemWindowTheme,
|
resolveSystemWindowTheme,
|
||||||
setupWindowResizeEvents,
|
setupWindowResizeEvents,
|
||||||
setupDragDropPrevention,
|
setupDragDropPrevention,
|
||||||
@@ -86,21 +86,18 @@ function applyAppIcon(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createMainWindow(): Promise<void> {
|
async function createMainWindow(): Promise<void> {
|
||||||
const isMac = process.platform === "darwin";
|
|
||||||
const iconPath = getWindowIconPath();
|
const iconPath = getWindowIconPath();
|
||||||
|
const systemTheme = resolveSystemWindowTheme();
|
||||||
|
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
show: false,
|
show: false,
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
titleBarStyle: "hidden",
|
...getMainWindowChromeOptions({
|
||||||
...(isMac
|
platform: process.platform,
|
||||||
? { trafficLightPosition: { x: 16, y: 14 } }
|
theme: systemTheme,
|
||||||
: {
|
}),
|
||||||
titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()),
|
|
||||||
autoHideMenuBar: true,
|
|
||||||
}),
|
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: getPreloadPath(),
|
preload: getPreloadPath(),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
|||||||
@@ -19,15 +19,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
|||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
getCurrentWindow: () => ({
|
getCurrentWindow: () => ({
|
||||||
startMove: (screenX: number, screenY: number) =>
|
|
||||||
ipcRenderer.send("paseo:window:startMove", { screenX, screenY }),
|
|
||||||
moving: (screenX: number, screenY: number) =>
|
|
||||||
ipcRenderer.send("paseo:window:moving", { screenX, screenY }),
|
|
||||||
endMove: () => ipcRenderer.send("paseo:window:endMove"),
|
|
||||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||||
setTitleBarTheme: (theme: "light" | "dark") =>
|
updateWindowControls: (update: {
|
||||||
ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme),
|
height?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
foregroundColor?: string;
|
||||||
|
}) => ipcRenderer.invoke("paseo:window:updateWindowControls", update),
|
||||||
onResized: (handler: EventHandler): (() => void) => {
|
onResized: (handler: EventHandler): (() => void) => {
|
||||||
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
||||||
handler(payload);
|
handler(payload);
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createWindowMoveState,
|
|
||||||
readWindowMovePayload,
|
|
||||||
resolveWindowMovePosition,
|
|
||||||
} from "./window-drag-coordinates";
|
|
||||||
|
|
||||||
describe("window-drag-coordinates", () => {
|
|
||||||
describe("readWindowMovePayload", () => {
|
|
||||||
it("returns finite screen coordinates", () => {
|
|
||||||
expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({
|
|
||||||
screenX: 1500,
|
|
||||||
screenY: 900,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-finite screen coordinates", () => {
|
|
||||||
expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull();
|
|
||||||
expect(
|
|
||||||
readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createWindowMoveState", () => {
|
|
||||||
it("derives finite offsets from the window position", () => {
|
|
||||||
expect(
|
|
||||||
createWindowMoveState({
|
|
||||||
payload: { screenX: 1500, screenY: 900 },
|
|
||||||
windowX: 1200,
|
|
||||||
windowY: 700,
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
offsetX: 300,
|
|
||||||
offsetY: 200,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-finite offsets", () => {
|
|
||||||
expect(
|
|
||||||
createWindowMoveState({
|
|
||||||
payload: { screenX: 1500, screenY: 900 },
|
|
||||||
windowX: Number.NaN,
|
|
||||||
windowY: 700,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveWindowMovePosition", () => {
|
|
||||||
it("rounds the next window position", () => {
|
|
||||||
expect(
|
|
||||||
resolveWindowMovePosition({
|
|
||||||
payload: { screenX: 1501.4, screenY: 902.6 },
|
|
||||||
state: { offsetX: 300, offsetY: 200 },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
x: 1201,
|
|
||||||
y: 703,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-finite next positions", () => {
|
|
||||||
expect(
|
|
||||||
resolveWindowMovePosition({
|
|
||||||
payload: { screenX: 1500, screenY: 900 },
|
|
||||||
state: { offsetX: Number.NaN, offsetY: 200 },
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
export type WindowMovePayload = {
|
|
||||||
screenX: number;
|
|
||||||
screenY: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WindowMoveState = {
|
|
||||||
offsetX: number;
|
|
||||||
offsetY: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WindowPosition = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WindowMovePayloadInput =
|
|
||||||
| {
|
|
||||||
screenX?: unknown;
|
|
||||||
screenY?: unknown;
|
|
||||||
}
|
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
function isFiniteCoordinate(value: unknown): value is number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null {
|
|
||||||
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
screenX: input.screenX,
|
|
||||||
screenY: input.screenY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWindowMoveState(input: {
|
|
||||||
payload: WindowMovePayload;
|
|
||||||
windowX: number;
|
|
||||||
windowY: number;
|
|
||||||
}): WindowMoveState | null {
|
|
||||||
const offsetX = input.payload.screenX - input.windowX;
|
|
||||||
const offsetY = input.payload.screenY - input.windowY;
|
|
||||||
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { offsetX, offsetY };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWindowMovePosition(input: {
|
|
||||||
payload: WindowMovePayload;
|
|
||||||
state: WindowMoveState;
|
|
||||||
}): WindowPosition | null {
|
|
||||||
const x = Math.round(input.payload.screenX - input.state.offsetX);
|
|
||||||
const y = Math.round(input.payload.screenY - input.state.offsetY);
|
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { getTitleBarOverlayOptions, readBadgeCount, readWindowTheme } from "./window-manager";
|
import {
|
||||||
|
applyWindowControlsOverlayUpdate,
|
||||||
|
createWindowControlsOverlayState,
|
||||||
|
getMainWindowChromeOptions,
|
||||||
|
getTitleBarOverlayOptions,
|
||||||
|
readBadgeCount,
|
||||||
|
readWindowControlsOverlayUpdate,
|
||||||
|
readWindowTheme,
|
||||||
|
resolveRuntimeTitleBarOverlayOptions,
|
||||||
|
} from "./window-manager";
|
||||||
|
|
||||||
describe("window-manager", () => {
|
describe("window-manager", () => {
|
||||||
describe("readBadgeCount", () => {
|
describe("readBadgeCount", () => {
|
||||||
@@ -39,7 +48,7 @@ describe("window-manager", () => {
|
|||||||
expect(getTitleBarOverlayOptions("light")).toEqual({
|
expect(getTitleBarOverlayOptions("light")).toEqual({
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
symbolColor: "#09090b",
|
symbolColor: "#09090b",
|
||||||
height: 48,
|
height: 29,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,7 +56,133 @@ describe("window-manager", () => {
|
|||||||
expect(getTitleBarOverlayOptions("dark")).toEqual({
|
expect(getTitleBarOverlayOptions("dark")).toEqual({
|
||||||
color: "#18181c",
|
color: "#18181c",
|
||||||
symbolColor: "#e4e4e7",
|
symbolColor: "#e4e4e7",
|
||||||
|
height: 29,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readWindowControlsOverlayUpdate", () => {
|
||||||
|
it("accepts partial runtime overlay updates", () => {
|
||||||
|
expect(
|
||||||
|
readWindowControlsOverlayUpdate({
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: "#18181c",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
height: 48,
|
height: 48,
|
||||||
|
backgroundColor: "#18181c",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty and invalid payloads", () => {
|
||||||
|
expect(readWindowControlsOverlayUpdate(undefined)).toBeNull();
|
||||||
|
expect(readWindowControlsOverlayUpdate({})).toBeNull();
|
||||||
|
expect(readWindowControlsOverlayUpdate({ height: 0 })).toBeNull();
|
||||||
|
expect(readWindowControlsOverlayUpdate({ backgroundColor: 12 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRuntimeTitleBarOverlayOptions", () => {
|
||||||
|
it("applies the VS Code height minus border adjustment", () => {
|
||||||
|
expect(
|
||||||
|
resolveRuntimeTitleBarOverlayOptions({
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
foregroundColor: "#09090b",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
color: "#ffffff",
|
||||||
|
symbolColor: "#09090b",
|
||||||
|
height: 47,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyWindowControlsOverlayUpdate", () => {
|
||||||
|
it("merges cached colors with later runtime height updates", () => {
|
||||||
|
const setTitleBarOverlay = vi.fn();
|
||||||
|
let state = createWindowControlsOverlayState("dark");
|
||||||
|
|
||||||
|
state = applyWindowControlsOverlayUpdate({
|
||||||
|
win: { setTitleBarOverlay },
|
||||||
|
current: state,
|
||||||
|
update: {
|
||||||
|
backgroundColor: "#18181c",
|
||||||
|
foregroundColor: "#e4e4e7",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
state = applyWindowControlsOverlayUpdate({
|
||||||
|
win: { setTitleBarOverlay },
|
||||||
|
current: state,
|
||||||
|
update: { height: 48 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state).toEqual({
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: "#18181c",
|
||||||
|
foregroundColor: "#e4e4e7",
|
||||||
|
});
|
||||||
|
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(1, {
|
||||||
|
color: "#18181c",
|
||||||
|
symbolColor: "#e4e4e7",
|
||||||
|
height: 28,
|
||||||
|
});
|
||||||
|
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(2, {
|
||||||
|
color: "#18181c",
|
||||||
|
symbolColor: "#e4e4e7",
|
||||||
|
height: 47,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMainWindowChromeOptions", () => {
|
||||||
|
it("uses frameless hidden title bars with overlay on windows", () => {
|
||||||
|
expect(
|
||||||
|
getMainWindowChromeOptions({
|
||||||
|
platform: "win32",
|
||||||
|
theme: "dark",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
titleBarStyle: "hidden",
|
||||||
|
frame: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: "#18181c",
|
||||||
|
symbolColor: "#e4e4e7",
|
||||||
|
height: 29,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses frameless hidden title bars with overlay on linux", () => {
|
||||||
|
expect(
|
||||||
|
getMainWindowChromeOptions({
|
||||||
|
platform: "linux",
|
||||||
|
theme: "light",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
titleBarStyle: "hidden",
|
||||||
|
frame: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: "#ffffff",
|
||||||
|
symbolColor: "#09090b",
|
||||||
|
height: 29,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the mac traffic-light path separate", () => {
|
||||||
|
expect(
|
||||||
|
getMainWindowChromeOptions({
|
||||||
|
platform: "darwin",
|
||||||
|
theme: "dark",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
titleBarStyle: "hidden",
|
||||||
|
titleBarOverlay: true,
|
||||||
|
trafficLightPosition: { x: 16, y: 14 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { app, BrowserWindow, ipcMain, nativeTheme } from "electron";
|
import { app, BrowserWindow, ipcMain, nativeTheme } from "electron";
|
||||||
import {
|
|
||||||
createWindowMoveState,
|
|
||||||
readWindowMovePayload,
|
|
||||||
resolveWindowMovePosition,
|
|
||||||
type WindowMoveState,
|
|
||||||
} from "./window-drag-coordinates.js";
|
|
||||||
|
|
||||||
export function readBadgeCount(input: unknown): number {
|
export function readBadgeCount(input: unknown): number {
|
||||||
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
|
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
|
||||||
@@ -15,6 +9,17 @@ export function readBadgeCount(input: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WindowTheme = "light" | "dark";
|
export type WindowTheme = "light" | "dark";
|
||||||
|
export type WindowControlsOverlayUpdate = {
|
||||||
|
height?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
foregroundColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WindowControlsOverlayState = {
|
||||||
|
height: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
foregroundColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function readWindowTheme(input: unknown): WindowTheme | null {
|
export function readWindowTheme(input: unknown): WindowTheme | null {
|
||||||
if (input === "light" || input === "dark") {
|
if (input === "light" || input === "dark") {
|
||||||
@@ -28,71 +33,111 @@ export function resolveSystemWindowTheme(): WindowTheme {
|
|||||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
|
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createWindowControlsOverlayState(theme: WindowTheme): WindowControlsOverlayState {
|
||||||
|
const overlay = getTitleBarOverlayOptions(theme);
|
||||||
|
return {
|
||||||
|
height: overlay.height ?? 29,
|
||||||
|
backgroundColor: overlay.color,
|
||||||
|
foregroundColor: overlay.symbolColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
|
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
|
||||||
if (theme === "dark") {
|
if (theme === "dark") {
|
||||||
return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 };
|
return { color: "#18181c", symbolColor: "#e4e4e7", height: 29 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { color: "#ffffff", symbolColor: "#09090b", height: 48 };
|
return { color: "#ffffff", symbolColor: "#09090b", height: 29 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainWindowChromeOptions(input: {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
theme: WindowTheme;
|
||||||
|
}): Pick<
|
||||||
|
Electron.BrowserWindowConstructorOptions,
|
||||||
|
"titleBarStyle" | "trafficLightPosition" | "frame" | "titleBarOverlay" | "autoHideMenuBar"
|
||||||
|
> {
|
||||||
|
if (input.platform === "darwin") {
|
||||||
|
return {
|
||||||
|
titleBarStyle: "hidden",
|
||||||
|
titleBarOverlay: true,
|
||||||
|
trafficLightPosition: { x: 16, y: 14 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
titleBarStyle: "hidden",
|
||||||
|
frame: false,
|
||||||
|
titleBarOverlay: getTitleBarOverlayOptions(input.theme),
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFiniteOverlayHeight(input: unknown): number | null {
|
||||||
|
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounded = Math.round(input);
|
||||||
|
return rounded >= 1 ? rounded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOverlayColor(input: unknown): string | null {
|
||||||
|
if (typeof input !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWindowControlsOverlayUpdate(input: unknown): WindowControlsOverlayUpdate | null {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = input as Record<string, unknown>;
|
||||||
|
const height = readFiniteOverlayHeight(candidate.height);
|
||||||
|
const backgroundColor = readOverlayColor(candidate.backgroundColor);
|
||||||
|
const foregroundColor = readOverlayColor(candidate.foregroundColor);
|
||||||
|
|
||||||
|
if (height === null && backgroundColor === null && foregroundColor === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(height !== null ? { height } : {}),
|
||||||
|
...(backgroundColor !== null ? { backgroundColor } : {}),
|
||||||
|
...(foregroundColor !== null ? { foregroundColor } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRuntimeTitleBarOverlayOptions(
|
||||||
|
state: WindowControlsOverlayState,
|
||||||
|
): Electron.TitleBarOverlayOptions {
|
||||||
|
return {
|
||||||
|
color: state.backgroundColor?.trim() === "" ? undefined : state.backgroundColor,
|
||||||
|
symbolColor: state.foregroundColor?.trim() === "" ? undefined : state.foregroundColor,
|
||||||
|
height: Math.max(0, state.height - 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyWindowControlsOverlayUpdate(input: {
|
||||||
|
win: Pick<BrowserWindow, "setTitleBarOverlay">;
|
||||||
|
current: WindowControlsOverlayState;
|
||||||
|
update: WindowControlsOverlayUpdate;
|
||||||
|
}): WindowControlsOverlayState {
|
||||||
|
const next: WindowControlsOverlayState = {
|
||||||
|
height: input.update.height ?? input.current.height,
|
||||||
|
backgroundColor: input.update.backgroundColor ?? input.current.backgroundColor,
|
||||||
|
foregroundColor: input.update.foregroundColor ?? input.current.foregroundColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
input.win.setTitleBarOverlay(resolveRuntimeTitleBarOverlayOptions(next));
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerWindowManager(): void {
|
export function registerWindowManager(): void {
|
||||||
// ---------------------------------------------------------------------------
|
const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>();
|
||||||
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
|
|
||||||
// State is keyed per window id to support multiple windows.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const moveStates = new Map<number, WindowMoveState>();
|
|
||||||
|
|
||||||
ipcMain.on("paseo:window:startMove", (event, payload: { screenX: number; screenY: number }) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
if (!win) return;
|
|
||||||
const movePayload = readWindowMovePayload(payload);
|
|
||||||
if (!movePayload) {
|
|
||||||
moveStates.delete(win.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [winX, winY] = win.getPosition();
|
|
||||||
const moveState = createWindowMoveState({
|
|
||||||
payload: movePayload,
|
|
||||||
windowX: winX,
|
|
||||||
windowY: winY,
|
|
||||||
});
|
|
||||||
if (!moveState) {
|
|
||||||
moveStates.delete(win.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
moveStates.set(win.id, moveState);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on("paseo:window:moving", (event, payload: { screenX: number; screenY: number }) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
if (!win) return;
|
|
||||||
const state = moveStates.get(win.id);
|
|
||||||
if (!state) return;
|
|
||||||
const movePayload = readWindowMovePayload(payload);
|
|
||||||
if (!movePayload) {
|
|
||||||
moveStates.delete(win.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextPosition = resolveWindowMovePosition({
|
|
||||||
payload: movePayload,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
if (!nextPosition) {
|
|
||||||
moveStates.delete(win.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
win.setPosition(nextPosition.x, nextPosition.y);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on("paseo:window:endMove", (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
|
||||||
if (win) moveStates.delete(win.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("browser-window-created", (_event, win) => {
|
|
||||||
win.on("closed", () => moveStates.delete(win.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
|
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
@@ -124,18 +169,25 @@ export function registerWindowManager(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("paseo:window:setTitleBarTheme", (event, theme?: unknown) => {
|
ipcMain.handle("paseo:window:updateWindowControls", (event, update?: unknown) => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
if (!win || process.platform === "darwin") {
|
if (!win || process.platform === "darwin") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextTheme = readWindowTheme(theme);
|
const nextUpdate = readWindowControlsOverlayUpdate(update);
|
||||||
if (!nextTheme) {
|
if (!nextUpdate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
win.setTitleBarOverlay(getTitleBarOverlayOptions(nextTheme));
|
const current =
|
||||||
|
overlayStateByWindow.get(win) ?? createWindowControlsOverlayState(resolveSystemWindowTheme());
|
||||||
|
const nextState = applyWindowControlsOverlayUpdate({
|
||||||
|
win,
|
||||||
|
current,
|
||||||
|
update: nextUpdate,
|
||||||
|
});
|
||||||
|
overlayStateByWindow.set(win, nextState);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user