mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Open script service URLs in-app or external
Adds a Service URLs setting (ask / in-Paseo / external browser) that controls where URLs from running scripts open on desktop. First click prompts with a "don't ask again" checkbox; in-Paseo opens a workspace browser tab. Closing a browser tab now clears its session partition.
This commit is contained in:
@@ -62,6 +62,11 @@ interface DropdownMenuContextValue {
|
||||
|
||||
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
|
||||
|
||||
export function useDropdownMenuClose(): () => void {
|
||||
const { setOpen } = useDropdownMenuContext("useDropdownMenuClose");
|
||||
return useCallback(() => setOpen(false), [setOpen]);
|
||||
}
|
||||
|
||||
function useDropdownMenuContext(componentName: string): DropdownMenuContextValue {
|
||||
const ctx = useContext(DropdownMenuContext);
|
||||
if (!ctx) {
|
||||
|
||||
@@ -21,8 +21,22 @@ export interface DesktopDialogOpenOptions {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DesktopDialogAskWithCheckboxOptions extends DesktopDialogAskOptions {
|
||||
checkboxLabel: string;
|
||||
checkboxChecked?: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopDialogAskWithCheckboxResult {
|
||||
confirmed: boolean;
|
||||
dontAskAgain: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopDialogBridge {
|
||||
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
|
||||
askWithCheckbox?: (
|
||||
message: string,
|
||||
options: DesktopDialogAskWithCheckboxOptions,
|
||||
) => Promise<DesktopDialogAskWithCheckboxResult>;
|
||||
open?: (options?: DesktopDialogOpenOptions) => Promise<string | string[] | null>;
|
||||
}
|
||||
|
||||
@@ -76,6 +90,7 @@ export interface DesktopBrowserShortcutEvent {
|
||||
|
||||
export interface DesktopBrowserBridge {
|
||||
setActivePane?: (browserId: string | null) => Promise<void>;
|
||||
clearPartition?: (browserId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopInvokeBridge {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SETTINGS,
|
||||
loadDesktopSettings,
|
||||
@@ -16,12 +17,15 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
sendBehavior: SendBehavior;
|
||||
serviceUrlBehavior: ServiceUrlBehavior;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
@@ -32,6 +36,7 @@ export interface Settings extends AppSettings {
|
||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
@@ -114,6 +119,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (updates.sendBehavior !== undefined) {
|
||||
appUpdates.sendBehavior = updates.sendBehavior;
|
||||
}
|
||||
if (updates.serviceUrlBehavior !== undefined) {
|
||||
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
@@ -162,6 +170,15 @@ export function useSettings(): UseSettingsReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
|
||||
const current =
|
||||
appQueryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||||
(await loadAppSettingsFromStorage());
|
||||
const next = { ...current, ...updates };
|
||||
appQueryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export async function loadAppSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
@@ -223,6 +240,12 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
if (
|
||||
typeof stored.serviceUrlBehavior === "string" &&
|
||||
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior as ServiceUrlBehavior)
|
||||
) {
|
||||
result.serviceUrlBehavior = stored.serviceUrlBehavior as ServiceUrlBehavior;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
useSettings,
|
||||
type AppSettings,
|
||||
type SendBehavior,
|
||||
type ServiceUrlBehavior,
|
||||
type Settings as EffectiveSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import { THEME_SWATCHES } from "@/styles/theme";
|
||||
@@ -190,14 +191,24 @@ const RELEASE_CHANNEL_OPTIONS = [
|
||||
{ value: "beta" as const, label: "Beta" },
|
||||
];
|
||||
|
||||
const SERVICE_URL_BEHAVIOR_LABELS: Record<ServiceUrlBehavior, string> = {
|
||||
ask: "Ask",
|
||||
"in-app": "In Paseo",
|
||||
external: "External browser",
|
||||
};
|
||||
|
||||
const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GeneralSectionProps {
|
||||
settings: AppSettings;
|
||||
isDesktopApp: boolean;
|
||||
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||
}
|
||||
|
||||
interface ThemeMenuItemProps {
|
||||
@@ -229,10 +240,33 @@ function ThemeMenuItem({
|
||||
);
|
||||
}
|
||||
|
||||
interface ServiceUrlBehaviorMenuItemProps {
|
||||
value: ServiceUrlBehavior;
|
||||
selected: boolean;
|
||||
onChange: (value: ServiceUrlBehavior) => void;
|
||||
}
|
||||
|
||||
function ServiceUrlBehaviorMenuItem({
|
||||
value,
|
||||
selected,
|
||||
onChange,
|
||||
}: ServiceUrlBehaviorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(value);
|
||||
}, [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{SERVICE_URL_BEHAVIOR_LABELS[value]}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralSection({
|
||||
settings,
|
||||
isDesktopApp,
|
||||
handleThemeChange,
|
||||
handleSendBehaviorChange,
|
||||
handleServiceUrlBehaviorChange,
|
||||
}: GeneralSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const iconSize = theme.iconSize.md;
|
||||
@@ -290,6 +324,32 @@ function GeneralSection({
|
||||
options={SEND_BEHAVIOR_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
{isDesktopApp ? (
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Service URLs</Text>
|
||||
<Text style={settingsStyles.rowHint}>Where to open URLs from running scripts</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger style={themeTriggerStyle}>
|
||||
<Text style={styles.themeTriggerText}>
|
||||
{SERVICE_URL_BEHAVIOR_LABELS[settings.serviceUrlBehavior]}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{SERVICE_URL_BEHAVIOR_VALUES.map((value) => (
|
||||
<ServiceUrlBehaviorMenuItem
|
||||
key={value}
|
||||
value={value}
|
||||
selected={settings.serviceUrlBehavior === value}
|
||||
onChange={handleServiceUrlBehaviorChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -768,6 +828,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleServiceUrlBehaviorChange = useCallback(
|
||||
(behavior: ServiceUrlBehavior) => {
|
||||
void updateSettings({ serviceUrlBehavior: behavior });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
@@ -949,8 +1016,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
return (
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleThemeChange={handleThemeChange}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
/>
|
||||
);
|
||||
case "shortcuts":
|
||||
|
||||
@@ -44,7 +44,12 @@ describe("workspace bulk close helpers", () => {
|
||||
expect(groups).toEqual({
|
||||
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
||||
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
|
||||
otherTabs: [{ tabId: "file_/repo/README.md" }],
|
||||
otherTabs: [
|
||||
{
|
||||
tabId: "file_/repo/README.md",
|
||||
target: { kind: "file", path: "/repo/README.md" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,7 +124,7 @@ describe("workspace bulk close helpers", () => {
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -158,7 +163,7 @@ describe("workspace bulk close helpers", () => {
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
export interface BulkClosableTabGroups {
|
||||
agentTabs: Array<{ tabId: string; agentId: string }>;
|
||||
terminalTabs: Array<{ tabId: string; terminalId: string }>;
|
||||
otherTabs: Array<{ tabId: string }>;
|
||||
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
|
||||
}
|
||||
|
||||
interface CloseWorkspaceTabWithCleanupInput {
|
||||
@@ -37,7 +37,7 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
|
||||
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId });
|
||||
continue;
|
||||
}
|
||||
groups.otherTabs.push({ tabId: tab.tabId });
|
||||
groups.otherTabs.push({ tabId: tab.tabId, target: tab.target });
|
||||
}
|
||||
|
||||
return groups;
|
||||
@@ -103,9 +103,9 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId } of groups.otherTabs) {
|
||||
for (const { tabId, target } of groups.otherTabs) {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
closeWorkspaceTabWithCleanup({ tabId, target });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,8 @@ import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { createWorkspaceBrowser } from "@/stores/browser-store";
|
||||
import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -1785,6 +1786,11 @@ function WorkspaceScreenContent({
|
||||
unpinWorkspaceAgent(persistenceKey, input.target.agentId);
|
||||
hideWorkspaceAgent(persistenceKey, input.target.agentId);
|
||||
}
|
||||
if (input.target?.kind === "browser") {
|
||||
const { browserId } = input.target;
|
||||
useBrowserStore.getState().removeBrowser(browserId);
|
||||
void getDesktopHost()?.browser?.clearPartition?.(browserId);
|
||||
}
|
||||
closeWorkspaceTab(persistenceKey, normalizedTabId);
|
||||
},
|
||||
[closeWorkspaceTab, hideWorkspaceAgent, persistenceKey, unpinWorkspaceAgent],
|
||||
@@ -2160,6 +2166,17 @@ function WorkspaceScreenContent({
|
||||
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
|
||||
const handleOpenUrlInBrowserTab = useCallback(
|
||||
(url: string) => {
|
||||
if (!persistenceKey || !getIsElectron()) {
|
||||
return;
|
||||
}
|
||||
const { browserId } = createWorkspaceBrowser({ initialUrl: url });
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
|
||||
},
|
||||
[openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
|
||||
const handleSelectSwitcherTab = useCallback(
|
||||
(key: string) => {
|
||||
navigateToTabId(key);
|
||||
@@ -2271,11 +2288,14 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
const handleCloseDraftOrFileTab = useCallback(
|
||||
function handleCloseDraftOrFileTab(tabId: string) {
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
function handleCloseDraftOrFileTab(input: {
|
||||
tabId: string;
|
||||
target?: WorkspaceTabTarget | null;
|
||||
}) {
|
||||
setHoveredTabKey((current) => (current === input.tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
|
||||
}
|
||||
},
|
||||
[closeWorkspaceTabWithCleanup, persistenceKey],
|
||||
@@ -2295,7 +2315,7 @@ function WorkspaceScreenContent({
|
||||
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
|
||||
return;
|
||||
}
|
||||
handleCloseDraftOrFileTab(tabId);
|
||||
handleCloseDraftOrFileTab({ tabId, target: tab.target });
|
||||
},
|
||||
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
||||
);
|
||||
@@ -2925,6 +2945,7 @@ function WorkspaceScreenContent({
|
||||
liveTerminalIds={liveTerminalIds}
|
||||
onScriptTerminalStarted={handleScriptTerminalStarted}
|
||||
onViewTerminal={handleViewScriptTerminal}
|
||||
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
@@ -3041,6 +3062,7 @@ function WorkspaceScreenContent({
|
||||
liveTerminalIds,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
handleOpenUrlInBrowserTab,
|
||||
showCompactButtonLabels,
|
||||
isGitCheckout,
|
||||
handleToggleExplorer,
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
useDropdownMenuClose,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { openServiceUrl } from "@/utils/open-service-url";
|
||||
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -28,6 +29,7 @@ interface WorkspaceScriptsButtonProps {
|
||||
liveTerminalIds?: readonly string[];
|
||||
onScriptTerminalStarted?: (terminalId: string) => void;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
onOpenUrlInBrowserTab?: (url: string) => void;
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
@@ -136,6 +138,7 @@ interface HostLinkProps {
|
||||
label: string;
|
||||
url: string | null;
|
||||
scriptName: string;
|
||||
onOpenInBrowserTab?: (url: string) => void;
|
||||
}
|
||||
|
||||
interface HostLinkChildrenProps {
|
||||
@@ -161,15 +164,18 @@ function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps):
|
||||
);
|
||||
}
|
||||
|
||||
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkProps): ReactElement {
|
||||
const disabled = !url;
|
||||
const closeMenu = useDropdownMenuClose();
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
if (url) void openExternalUrl(url);
|
||||
if (!url) return;
|
||||
closeMenu();
|
||||
void openServiceUrl(url, { openInApp: onOpenInBrowserTab });
|
||||
},
|
||||
[url],
|
||||
[url, onOpenInBrowserTab, closeMenu],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
@@ -219,6 +225,7 @@ interface ScriptRowProps {
|
||||
isStartPending: boolean;
|
||||
onStartScript: (scriptName: string) => void;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
onOpenUrlInBrowserTab?: (url: string) => void;
|
||||
}
|
||||
|
||||
function resolveScriptIconColorMapping(args: {
|
||||
@@ -244,6 +251,7 @@ function ScriptRow({
|
||||
isStartPending,
|
||||
onStartScript,
|
||||
onViewTerminal,
|
||||
onOpenUrlInBrowserTab,
|
||||
}: ScriptRowProps): ReactElement {
|
||||
const isRunning = script.lifecycle === "running";
|
||||
const isService = (script.type ?? "service") === "service";
|
||||
@@ -340,6 +348,7 @@ function ScriptRow({
|
||||
label={link.label}
|
||||
url={link.url}
|
||||
scriptName={script.scriptName}
|
||||
onOpenInBrowserTab={onOpenUrlInBrowserTab}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -355,6 +364,7 @@ export function WorkspaceScriptsButton({
|
||||
liveTerminalIds = [],
|
||||
onScriptTerminalStarted,
|
||||
onViewTerminal,
|
||||
onOpenUrlInBrowserTab,
|
||||
hideLabels,
|
||||
}: WorkspaceScriptsButtonProps): ReactElement | null {
|
||||
const toast = useToast();
|
||||
@@ -438,6 +448,7 @@ export function WorkspaceScriptsButton({
|
||||
isStartPending={startScriptMutation.isPending}
|
||||
onStartScript={handleStartScript}
|
||||
onViewTerminal={onViewTerminal}
|
||||
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
51
packages/app/src/utils/open-service-url.ts
Normal file
51
packages/app/src/utils/open-service-url.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import {
|
||||
loadAppSettingsFromStorage,
|
||||
persistAppSettings,
|
||||
type ServiceUrlBehavior,
|
||||
} from "@/hooks/use-settings";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
export interface OpenServiceUrlOptions {
|
||||
openInApp?: (url: string) => void;
|
||||
}
|
||||
|
||||
export async function openServiceUrl(url: string, options?: OpenServiceUrlOptions): Promise<void> {
|
||||
const openInApp = options?.openInApp;
|
||||
if (!openInApp || !isElectronRuntime()) {
|
||||
await openExternalUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
const behavior = await resolveBehavior(url);
|
||||
if (behavior === "in-app") {
|
||||
openInApp(url);
|
||||
return;
|
||||
}
|
||||
await openExternalUrl(url);
|
||||
}
|
||||
|
||||
async function resolveBehavior(url: string): Promise<Exclude<ServiceUrlBehavior, "ask">> {
|
||||
const settings = await loadAppSettingsFromStorage();
|
||||
if (settings.serviceUrlBehavior === "in-app" || settings.serviceUrlBehavior === "external") {
|
||||
return settings.serviceUrlBehavior;
|
||||
}
|
||||
|
||||
const askWithCheckbox = getDesktopHost()?.dialog?.askWithCheckbox;
|
||||
if (typeof askWithCheckbox !== "function") {
|
||||
return "external";
|
||||
}
|
||||
|
||||
const result = await askWithCheckbox(`Open ${url}?`, {
|
||||
title: "Open service URL",
|
||||
okLabel: "In Paseo",
|
||||
cancelLabel: "External browser",
|
||||
checkboxLabel: "Don't ask again",
|
||||
});
|
||||
|
||||
const choice: Exclude<ServiceUrlBehavior, "ask"> = result.confirmed ? "in-app" : "external";
|
||||
if (result.dontAskAgain) {
|
||||
await persistAppSettings({ serviceUrlBehavior: choice });
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
@@ -7,6 +7,11 @@ interface AskOptions {
|
||||
kind?: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
interface AskWithCheckboxOptions extends AskOptions {
|
||||
checkboxLabel: string;
|
||||
checkboxChecked?: boolean;
|
||||
}
|
||||
|
||||
interface OpenOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
@@ -35,6 +40,27 @@ export function registerDialogHandlers(): void {
|
||||
return result.response === 1;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"paseo:dialog:askWithCheckbox",
|
||||
async (event, message: string, options: AskWithCheckboxOptions) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showMessageBox(win ?? BrowserWindow.getFocusedWindow()!, {
|
||||
type: resolveDialogType(options.kind),
|
||||
title: options.title ?? "Confirm",
|
||||
message,
|
||||
buttons: [options.cancelLabel ?? "Cancel", options.okLabel ?? "OK"],
|
||||
defaultId: 1,
|
||||
cancelId: 0,
|
||||
checkboxLabel: options.checkboxLabel,
|
||||
checkboxChecked: options.checkboxChecked ?? false,
|
||||
});
|
||||
return {
|
||||
confirmed: result.response === 1,
|
||||
dontAskAgain: result.checkboxChecked,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("paseo:dialog:open", async (event, options?: OpenOptions) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const properties: Electron.OpenDialogOptions["properties"] = [];
|
||||
|
||||
@@ -9,7 +9,7 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol, session } from "electron";
|
||||
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||
import {
|
||||
parseCliPassthroughArgsFromArgv,
|
||||
@@ -219,6 +219,14 @@ ipcMain.handle("paseo:browser:set-active-pane", (_event, browserId: unknown) =>
|
||||
setActivePaseoBrowserPaneId(typeof browserId === "string" ? browserId : null);
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknown) => {
|
||||
if (typeof browserId !== "string" || browserId.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
const partition = `persist:paseo-browser-${browserId}`;
|
||||
await session.fromPartition(partition).clearStorageData();
|
||||
});
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||
]);
|
||||
|
||||
@@ -43,6 +43,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
dialog: {
|
||||
ask: (message: string, options?: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke("paseo:dialog:ask", message, options),
|
||||
askWithCheckbox: (message: string, options: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke("paseo:dialog:askWithCheckbox", message, options),
|
||||
open: (options?: Record<string, unknown>) => ipcRenderer.invoke("paseo:dialog:open", options),
|
||||
},
|
||||
notification: {
|
||||
@@ -60,5 +62,7 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
browser: {
|
||||
setActivePane: (browserId: string | null) =>
|
||||
ipcRenderer.invoke("paseo:browser:set-active-pane", browserId),
|
||||
clearPartition: (browserId: string) =>
|
||||
ipcRenderer.invoke("paseo:browser:clear-partition", browserId),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user