mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
fix(desktop): recognize managed import targets
Desktop ownership is explicit application state. Avoid inferring it from host addresses or launching a second status probe, while retaining validation when an import starts.
This commit is contained in:
@@ -41,6 +41,7 @@ test("imports from the product Integrations row and renders success, failure, an
|
||||
electronApp = await launchProduct();
|
||||
const page = await electronApp.firstWindow();
|
||||
await openIntegrations(page);
|
||||
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
|
||||
|
||||
await openImport(page);
|
||||
await expect(
|
||||
|
||||
@@ -17,7 +17,11 @@ interface DaemonStatusData {
|
||||
logs: DesktopDaemonLogs;
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
interface UseDaemonStatusOptions {
|
||||
refetchOnMount?: boolean | "always";
|
||||
}
|
||||
|
||||
export function useDaemonStatus(options: UseDaemonStatusOptions = {}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
@@ -26,7 +30,7 @@ export function useDaemonStatus() {
|
||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnMount: options.refetchOnMount ?? "always",
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
||||
@@ -15,8 +13,6 @@ export interface EnableBuiltInDaemonOption {
|
||||
|
||||
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
const isElectron = getIsElectron();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
const hosts = useHosts();
|
||||
const { settings, updateSettings } = useDesktopSettings();
|
||||
const { data, setStatus, refetch } = useDaemonStatus();
|
||||
const { enable } = useBuiltInDaemonManagement({
|
||||
@@ -27,11 +23,7 @@ export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
refreshStatus: refetch,
|
||||
});
|
||||
|
||||
const isLocalhostConfigured =
|
||||
localDaemon.status === "resolved" &&
|
||||
localDaemon.serverId !== null &&
|
||||
hosts.some((host) => host.serverId === localDaemon.serverId);
|
||||
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
|
||||
const visible = isElectron && data?.status.desktopManaged === false;
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
void (async () => {
|
||||
|
||||
@@ -174,17 +174,6 @@ export type DesktopImportOutput =
|
||||
| { runId: string; type: "status"; succeeded: boolean };
|
||||
|
||||
export interface DesktopImportsBridge {
|
||||
getAvailability?: (input: { source: string }) => Promise<{
|
||||
available: boolean;
|
||||
reason:
|
||||
| "unsupported-source"
|
||||
| "host-not-running"
|
||||
| "nonlocal-host"
|
||||
| "password-protected"
|
||||
| "host-version-mismatch"
|
||||
| "unavailable"
|
||||
| null;
|
||||
}>;
|
||||
run?: (input: { source: string }) => Promise<{ runId: string }>;
|
||||
onOutput?: (handler: (output: DesktopImportOutput) => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,116 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="#171717"/>
|
||||
<path d="M9 10.5C9 8.57 10.57 7 12.5 7h7C21.43 7 23 8.57 23 10.5v11c0 1.93-1.57 3.5-3.5 3.5h-7A3.5 3.5 0 0 1 9 21.5v-11Z" stroke="white" stroke-width="2"/>
|
||||
<path d="M13 12h6M13 16h6M13 20h3" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<svg width="115" height="174" viewBox="0 0 115 174" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="36" width="23" height="29">
|
||||
<path d="M0.499023 63.6992V37.251H22.373V63.6992H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_41_20251)">
|
||||
<path d="M4.57422 63.6992H22.373V37.251H4.57422C3.58785 37.2511 2.78711 38.0517 2.78711 39.0381V61.9121C2.78725 62.8984 3.58794 63.6991 4.57422 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask1_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="36" width="24" height="29">
|
||||
<path d="M18.7979 63.6992V37.251H40.6719V63.6992H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_41_20251)">
|
||||
<path d="M36.5977 63.6992H18.7988V37.251H36.5977C37.584 37.2511 38.3848 38.0517 38.3848 39.0381V61.9121C38.3846 62.8984 37.5839 63.6991 36.5977 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask2_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="73" width="23" height="28">
|
||||
<path d="M0.499023 100.297V73.8486H22.373V100.297H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask2_41_20251)">
|
||||
<path d="M4.57422 100.297H22.373V73.8486H4.57422C3.58785 73.8488 2.78711 74.6493 2.78711 75.6357V98.5098C2.78725 99.496 3.58794 100.297 4.57422 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask3_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="73" width="24" height="28">
|
||||
<path d="M18.7979 100.297V73.8486H40.6719V100.297H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask3_41_20251)">
|
||||
<path d="M36.5977 100.297H18.7988V73.8486H36.5977C37.584 73.8488 38.3848 74.6493 38.3848 75.6357V98.5098C38.3846 99.496 37.5839 100.297 36.5977 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask4_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="109" width="23" height="29">
|
||||
<path d="M0.499023 136.896V110.447H22.373V136.896H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask4_41_20251)">
|
||||
<path d="M4.57422 136.896H22.373V110.447H4.57422C3.58785 110.447 2.78711 111.248 2.78711 112.234V135.108C2.78725 136.095 3.58794 136.895 4.57422 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask5_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="109" width="24" height="29">
|
||||
<path d="M18.7979 136.896V110.447H40.6719V136.896H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask5_41_20251)">
|
||||
<path d="M36.5977 136.896H18.7988V110.447H36.5977C37.584 110.447 38.3848 111.248 38.3848 112.234V135.108C38.3846 136.095 37.5839 136.895 36.5977 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask6_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="146" width="24" height="28">
|
||||
<path d="M18.7979 173.493V147.045H40.6719V173.493H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask6_41_20251)">
|
||||
<path d="M22.873 173.493H40.6719V147.045H22.873C21.8867 147.045 21.0859 147.846 21.0859 148.832V171.706C21.0861 172.692 21.8868 173.493 22.873 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask7_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="146" width="24" height="28">
|
||||
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask7_41_20251)">
|
||||
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask8_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="146" width="24" height="28">
|
||||
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask8_41_20251)">
|
||||
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask9_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="146" width="24" height="28">
|
||||
<path d="M73.6963 173.493V147.045H95.5703V173.493H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask9_41_20251)">
|
||||
<path d="M91.4941 173.493H73.6953V147.045H91.4941C92.4805 147.045 93.2812 147.846 93.2812 148.832V171.706C93.2811 172.692 92.4804 173.493 91.4941 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask10_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="109" width="24" height="29">
|
||||
<path d="M73.6963 136.896V110.447H95.5703V136.896H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask10_41_20251)">
|
||||
<path d="M77.7695 136.896H95.5684V110.447H77.7695C76.7832 110.447 75.9824 111.248 75.9824 112.234V135.108C75.9826 136.095 76.7833 136.895 77.7695 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask11_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="109" width="24" height="29">
|
||||
<path d="M91.9932 136.896V110.447H113.867V136.896H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask11_41_20251)">
|
||||
<path d="M109.793 136.896H91.9941V110.447H109.793C110.779 110.447 111.58 111.248 111.58 112.234V135.108C111.58 136.095 110.779 136.895 109.793 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask12_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="0" width="24" height="28">
|
||||
<path d="M18.7979 27.1006V0.652344H40.6719V27.1006H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask12_41_20251)">
|
||||
<path d="M22.873 27.1006H40.6719V0.652344H22.873C21.8867 0.652488 21.0859 1.45305 21.0859 2.43945V25.3135C21.0861 26.2998 21.8868 27.1004 22.873 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask13_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="0" width="24" height="28">
|
||||
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask13_41_20251)">
|
||||
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask14_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="0" width="24" height="28">
|
||||
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask14_41_20251)">
|
||||
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask15_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="0" width="24" height="28">
|
||||
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask15_41_20251)">
|
||||
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask16_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="0" width="24" height="28">
|
||||
<path d="M91.9932 27.1006V0.652344H113.867V27.1006H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask16_41_20251)">
|
||||
<path d="M109.793 27.1006H91.9941V0.652344H109.793C110.779 0.652488 111.58 1.45305 111.58 2.43945V25.3135C111.58 26.2998 110.779 27.1004 109.793 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask17_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="36" width="24" height="29">
|
||||
<path d="M73.6963 63.6992V37.251H95.5703V63.6992H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask17_41_20251)">
|
||||
<path d="M77.7695 63.6992H95.5684V37.251H77.7695C76.7832 37.2511 75.9824 38.0517 75.9824 39.0381V61.9121C75.9826 62.8984 76.7833 63.6991 77.7695 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask18_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="36" width="24" height="29">
|
||||
<path d="M91.9932 63.6992V37.251H113.867V63.6992H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask18_41_20251)">
|
||||
<path d="M109.793 63.6992H91.9941V37.251H109.793C110.779 37.2511 111.58 38.0517 111.58 39.0381V61.9121C111.58 62.8984 110.779 63.6991 109.793 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 410 B After Width: | Height: | Size: 7.9 KiB |
@@ -3,5 +3,6 @@ import type { ImportSourceDescriptor } from "./import-sheet";
|
||||
export const conductorImportSource: ImportSourceDescriptor = {
|
||||
id: "conductor",
|
||||
title: "Conductor",
|
||||
// Official light letter mark from https://www.conductor.build/brandkit.
|
||||
icon: require("./conductor.svg"),
|
||||
};
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isElectronRuntimeMac } from "@/desktop/host";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ImportSheet, type ImportSourceDescriptor, useImportAvailability } from "./import-sheet";
|
||||
import { ImportSheet, type ImportSourceDescriptor } from "./import-sheet";
|
||||
|
||||
export function ExternalImport({ source }: { source: ImportSourceDescriptor }) {
|
||||
const { t } = useTranslation();
|
||||
const importFlow = useImportAvailability(source.id);
|
||||
const { data } = useDaemonStatus({ refetchOnMount: false });
|
||||
const desktopManaged = data?.status.desktopManaged;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const open = useCallback(() => setVisible(true), []);
|
||||
const close = useCallback(() => setVisible(false), []);
|
||||
if (!isElectronRuntimeMac()) return null;
|
||||
|
||||
const available = desktopManaged === true;
|
||||
let reason: string | null = null;
|
||||
if (desktopManaged === undefined) {
|
||||
reason = t("desktop.integrations.import.availability.checking");
|
||||
} else if (!available) {
|
||||
reason = t("desktop.integrations.import.availability.host-not-running");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
@@ -19,29 +33,39 @@ export function ExternalImport({ source }: { source: ImportSourceDescriptor }) {
|
||||
>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<Image source={source.icon} style={styles.icon} />
|
||||
<View style={styles.iconFrame}>
|
||||
<Image source={source.icon} style={styles.icon} resizeMode="contain" />
|
||||
</View>
|
||||
<Text style={settingsStyles.rowTitle}>{source.title}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{importFlow.reason ?? t("desktop.integrations.import.description")}
|
||||
{reason ?? t("desktop.integrations.import.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={importFlow.open}
|
||||
disabled={!importFlow.available}
|
||||
onPress={open}
|
||||
disabled={!available}
|
||||
testID={`${source.id}-import-open`}
|
||||
>
|
||||
{t("desktop.integrations.import.actions.import")}
|
||||
</Button>
|
||||
</View>
|
||||
<ImportSheet source={source} visible={importFlow.visible} onClose={importFlow.close} />
|
||||
<ImportSheet source={source} visible={visible} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
icon: { width: theme.iconSize.md, height: theme.iconSize.md },
|
||||
iconFrame: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: "#282423",
|
||||
},
|
||||
icon: { width: 12, height: 18 },
|
||||
}));
|
||||
|
||||
@@ -19,45 +19,6 @@ export interface ImportSourceDescriptor {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function useImportAvailability(source: string) {
|
||||
const { t } = useTranslation();
|
||||
const [availability, setAvailability] = useState<{
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
}>({
|
||||
available: false,
|
||||
reason: t("desktop.integrations.import.availability.checking"),
|
||||
});
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let retry: ReturnType<typeof setTimeout> | null = null;
|
||||
async function loadAvailability() {
|
||||
const next = await getDesktopHost()?.imports?.getAvailability?.({ source });
|
||||
if (active && next) {
|
||||
setAvailability({
|
||||
available: next.available,
|
||||
reason: next.reason ? t(`desktop.integrations.import.availability.${next.reason}`) : null,
|
||||
});
|
||||
if (next.reason === "host-not-running") {
|
||||
retry = setTimeout(() => void loadAvailability(), 2_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadAvailability();
|
||||
return () => {
|
||||
active = false;
|
||||
if (retry) clearTimeout(retry);
|
||||
};
|
||||
}, [source, t]);
|
||||
return {
|
||||
...availability,
|
||||
visible,
|
||||
open: () => setVisible(true),
|
||||
close: () => setVisible(false),
|
||||
};
|
||||
}
|
||||
|
||||
export function ImportSheet({
|
||||
source,
|
||||
visible,
|
||||
|
||||
@@ -1120,12 +1120,7 @@ export const ar: TranslationResources = {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "جارٍ التحقق من توفّر الاستيراد…",
|
||||
"unsupported-source": "مصدر الاستيراد هذا غير متاح.",
|
||||
"host-not-running": "شغّل المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.",
|
||||
"nonlocal-host": "الاستيراد غير متاح لمضيف غير محلي.",
|
||||
"password-protected": "الاستيراد غير متاح أثناء حماية المضيف المحلي بكلمة مرور.",
|
||||
"host-version-mismatch": "حدّث المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.",
|
||||
unavailable: "استيراد تطبيق سطح المكتب غير متاح.",
|
||||
},
|
||||
actions: { import: "استيراد", importing: "جارٍ الاستيراد…", done: "تم" },
|
||||
unavailable: "الاستيراد عبر تطبيق سطح المكتب غير متاح.",
|
||||
|
||||
@@ -1131,12 +1131,7 @@ export const en = {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Checking import availability…",
|
||||
"unsupported-source": "This import source is unavailable.",
|
||||
"host-not-running": "Start the Desktop-managed host before importing.",
|
||||
"nonlocal-host": "Import is unavailable for a nonlocal host.",
|
||||
"password-protected": "Import is unavailable while the local host is password-protected.",
|
||||
"host-version-mismatch": "Update the Desktop-managed host before importing.",
|
||||
unavailable: "Desktop import is unavailable.",
|
||||
},
|
||||
actions: { import: "Import", importing: "Importing…", done: "Done" },
|
||||
unavailable: "Desktop import is unavailable.",
|
||||
|
||||
@@ -1160,13 +1160,7 @@ export const es: TranslationResources = {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Comprobando la disponibilidad de la importación…",
|
||||
"unsupported-source": "Esta fuente de importación no está disponible.",
|
||||
"host-not-running": "Inicia el host gestionado por Desktop antes de importar.",
|
||||
"nonlocal-host": "La importación no está disponible para un host no local.",
|
||||
"password-protected":
|
||||
"La importación no está disponible mientras el host local esté protegido con contraseña.",
|
||||
"host-version-mismatch": "Actualiza el host gestionado por Desktop antes de importar.",
|
||||
unavailable: "La importación de Desktop no está disponible.",
|
||||
},
|
||||
actions: { import: "Importar", importing: "Importando…", done: "Listo" },
|
||||
unavailable: "La importación de escritorio no está disponible.",
|
||||
|
||||
@@ -1162,13 +1162,7 @@ export const fr: TranslationResources = {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Vérification de la disponibilité de l’importation…",
|
||||
"unsupported-source": "Cette source d’importation est indisponible.",
|
||||
"host-not-running": "Démarrez l’hôte géré par Desktop avant l’importation.",
|
||||
"nonlocal-host": "L’importation est indisponible pour un hôte distant.",
|
||||
"password-protected":
|
||||
"L’importation est indisponible tant que l’hôte local est protégé par mot de passe.",
|
||||
"host-version-mismatch": "Mettez à jour l’hôte géré par Desktop avant l’importation.",
|
||||
unavailable: "L’importation Desktop est indisponible.",
|
||||
},
|
||||
actions: { import: "Importer", importing: "Importation…", done: "Terminé" },
|
||||
unavailable: "L’importation depuis l’application de bureau est indisponible.",
|
||||
|
||||
@@ -1135,13 +1135,7 @@ export const ja: TranslationResources = {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "インポートが利用可能か確認しています…",
|
||||
"unsupported-source": "このインポート元は利用できません。",
|
||||
"host-not-running": "インポートする前にDesktop管理ホストを起動してください。",
|
||||
"nonlocal-host": "ローカル以外のホストではインポートできません。",
|
||||
"password-protected":
|
||||
"ローカルホストがパスワード保護されている間はインポートできません。",
|
||||
"host-version-mismatch": "インポートする前にDesktop管理ホストを更新してください。",
|
||||
unavailable: "Desktopインポートは利用できません。",
|
||||
},
|
||||
actions: { import: "インポート", importing: "インポート中…", done: "完了" },
|
||||
unavailable: "デスクトップインポートは利用できません。",
|
||||
|
||||
@@ -1147,13 +1147,7 @@ export const ptBR: TranslationResources = {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Verificando a disponibilidade da importação…",
|
||||
"unsupported-source": "Esta fonte de importação não está disponível.",
|
||||
"host-not-running": "Inicie o host gerenciado pelo Desktop antes de importar.",
|
||||
"nonlocal-host": "A importação não está disponível para um host não local.",
|
||||
"password-protected":
|
||||
"A importação não está disponível enquanto o host local estiver protegido por senha.",
|
||||
"host-version-mismatch": "Atualize o host gerenciado pelo Desktop antes de importar.",
|
||||
unavailable: "A importação do Desktop não está disponível.",
|
||||
},
|
||||
actions: { import: "Importar", importing: "Importando…", done: "Concluído" },
|
||||
unavailable: "A importação pelo aplicativo para desktop não está disponível.",
|
||||
|
||||
@@ -1150,12 +1150,7 @@ export const ru: TranslationResources = {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Проверка доступности импорта…",
|
||||
"unsupported-source": "Этот источник импорта недоступен.",
|
||||
"host-not-running": "Запустите управляемый Desktop хост перед импортом.",
|
||||
"nonlocal-host": "Импорт недоступен для нелокального хоста.",
|
||||
"password-protected": "Импорт недоступен, пока локальный хост защищён паролем.",
|
||||
"host-version-mismatch": "Обновите управляемый Desktop хост перед импортом.",
|
||||
unavailable: "Импорт в Desktop недоступен.",
|
||||
},
|
||||
actions: { import: "Импортировать", importing: "Импорт…", done: "Готово" },
|
||||
unavailable: "Импорт в настольном приложении недоступен.",
|
||||
|
||||
@@ -1106,12 +1106,7 @@ export const zhCN: TranslationResources = {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "正在检查导入是否可用…",
|
||||
"unsupported-source": "此导入来源不可用。",
|
||||
"host-not-running": "请先启动由桌面端管理的主机再导入。",
|
||||
"nonlocal-host": "非本地主机无法使用导入。",
|
||||
"password-protected": "本地主机受密码保护时无法导入。",
|
||||
"host-version-mismatch": "请先更新由桌面端管理的主机再导入。",
|
||||
unavailable: "桌面端导入不可用。",
|
||||
},
|
||||
actions: { import: "导入", importing: "正在导入…", done: "完成" },
|
||||
unavailable: "桌面端导入不可用。",
|
||||
|
||||
@@ -33,9 +33,6 @@ const imports = new DesktopImportRunner({
|
||||
});
|
||||
|
||||
export function registerImportIpc(): void {
|
||||
ipcMain.handle("paseo:imports:availability", (_event, input: unknown) =>
|
||||
imports.availability(readSource(input)),
|
||||
);
|
||||
ipcMain.handle("paseo:imports:run", async (event, input: unknown) => {
|
||||
const source = readSource(input);
|
||||
return {
|
||||
|
||||
@@ -79,26 +79,30 @@ test("reports library failures and closes the host", async () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ ...eligibleTarget, passwordProtected: true }, "password-protected"],
|
||||
[{ ...eligibleTarget, listen: "10.0.0.5:6767" }, "nonlocal-host"],
|
||||
[{ ...eligibleTarget, status: "stopped" as const }, "host-not-running"],
|
||||
[{ ...eligibleTarget, daemonVersion: "0.1.109" }, "host-version-mismatch"],
|
||||
] as const)("returns a localizable availability reason", async (target, reason) => {
|
||||
[
|
||||
{ ...eligibleTarget, passwordProtected: true },
|
||||
"Import is unavailable while the local host is password-protected.",
|
||||
],
|
||||
[
|
||||
{ ...eligibleTarget, status: "stopped" as const },
|
||||
"Import requires the running Paseo Desktop-managed host.",
|
||||
],
|
||||
[
|
||||
{ ...eligibleTarget, daemonVersion: "0.1.109" },
|
||||
"Update the Desktop-managed host before importing.",
|
||||
],
|
||||
] as const)("refuses an ineligible import target", async (target, message) => {
|
||||
const runner = createRunner({ target });
|
||||
|
||||
await expect(runner.availability("source-fixture")).resolves.toEqual({
|
||||
available: false,
|
||||
reason,
|
||||
});
|
||||
await expect(runner.run("source-fixture", () => undefined)).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
test.each(["[::1]:6767", "::1:6767"])("accepts local IPv6 listen address %s", async (listen) => {
|
||||
const runner = createRunner({ target: { ...eligibleTarget, listen } });
|
||||
|
||||
await expect(runner.availability("source-fixture")).resolves.toEqual({
|
||||
available: true,
|
||||
reason: null,
|
||||
test("accepts a Desktop-managed host without inferring ownership from its listen address", async () => {
|
||||
const runner = createRunner({
|
||||
target: { ...eligibleTarget, listen: "0.0.0.0:6767" },
|
||||
});
|
||||
|
||||
await expect(runner.run("source-fixture", () => undefined)).resolves.toBe("run-1");
|
||||
});
|
||||
|
||||
test("rejects an unregistered source before target lookup or connection", async () => {
|
||||
|
||||
@@ -21,14 +21,6 @@ export type DesktopImportOutput =
|
||||
| { runId: string; type: "event"; event: ImportEvent }
|
||||
| { runId: string; type: "status"; succeeded: boolean };
|
||||
|
||||
export type ImportUnavailableReason =
|
||||
| "unsupported-source"
|
||||
| "host-not-running"
|
||||
| "nonlocal-host"
|
||||
| "password-protected"
|
||||
| "host-version-mismatch"
|
||||
| "unavailable";
|
||||
|
||||
type ConnectedImportHost = PaseoImportHost & { close(): Promise<void> };
|
||||
|
||||
interface ImportRunnerDependencies {
|
||||
@@ -44,21 +36,6 @@ export class DesktopImportRunner {
|
||||
|
||||
constructor(private readonly dependencies: ImportRunnerDependencies) {}
|
||||
|
||||
async availability(
|
||||
sourceId: string,
|
||||
): Promise<{ available: boolean; reason: ImportUnavailableReason | null }> {
|
||||
try {
|
||||
this.requireSource(sourceId);
|
||||
assertEligibleTarget(await this.dependencies.getTarget());
|
||||
return { available: true, reason: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
reason: error instanceof ImportEligibilityError ? error.reason : "unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async run(sourceId: string, emit: (output: DesktopImportOutput) => void): Promise<string> {
|
||||
const source = this.requireSource(sourceId);
|
||||
if (this.activeRunId) throw new Error("An import is already running.");
|
||||
@@ -110,10 +87,7 @@ export class DesktopImportRunner {
|
||||
private requireSource(sourceId: string): ImportSourceInput {
|
||||
const source = this.dependencies.sources.get(sourceId);
|
||||
if (!source) {
|
||||
throw new ImportEligibilityError(
|
||||
"unsupported-source",
|
||||
`Unsupported import source: ${sourceId}`,
|
||||
);
|
||||
throw new Error(`Unsupported import source: ${sourceId}`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
@@ -121,48 +95,16 @@ export class DesktopImportRunner {
|
||||
|
||||
export function assertEligibleTarget(target: ImportTarget): void {
|
||||
if (target.status !== "running" || !target.desktopManaged) {
|
||||
throw new ImportEligibilityError(
|
||||
"host-not-running",
|
||||
"Import requires the running Paseo Desktop-managed host.",
|
||||
);
|
||||
}
|
||||
if (!isLocalListen(target.listen)) {
|
||||
throw new ImportEligibilityError("nonlocal-host", "Import is unavailable for a nonlocal host.");
|
||||
throw new Error("Import requires the running Paseo Desktop-managed host.");
|
||||
}
|
||||
if (target.passwordProtected) {
|
||||
throw new ImportEligibilityError(
|
||||
"password-protected",
|
||||
"Import is unavailable while the local host is password-protected.",
|
||||
);
|
||||
throw new Error("Import is unavailable while the local host is password-protected.");
|
||||
}
|
||||
if (normalizeVersion(target.daemonVersion) !== normalizeVersion(target.appVersion)) {
|
||||
throw new ImportEligibilityError(
|
||||
"host-version-mismatch",
|
||||
"Update the Desktop-managed host before importing.",
|
||||
);
|
||||
throw new Error("Update the Desktop-managed host before importing.");
|
||||
}
|
||||
}
|
||||
|
||||
class ImportEligibilityError extends Error {
|
||||
constructor(
|
||||
readonly reason: ImportUnavailableReason,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalListen(listen: string | null): boolean {
|
||||
if (!listen) return false;
|
||||
if (listen.startsWith("unix://") || listen.startsWith("pipe://") || listen.startsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
const endpoint = listen.replace(/^tcp:\/\//, "").toLowerCase();
|
||||
if (endpoint.startsWith("[::1]:") || endpoint.startsWith("::1:")) return true;
|
||||
const host = endpoint.split(":")[0];
|
||||
return host === "127.0.0.1" || host === "localhost" || host === "[::1]";
|
||||
}
|
||||
|
||||
function normalizeVersion(version: string | null): string | null {
|
||||
return version?.trim().replace(/^v/i, "") || null;
|
||||
}
|
||||
|
||||
@@ -43,18 +43,6 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
},
|
||||
},
|
||||
imports: {
|
||||
getAvailability: (input: { source: string }) =>
|
||||
ipcRenderer.invoke("paseo:imports:availability", input) as Promise<{
|
||||
available: boolean;
|
||||
reason:
|
||||
| "unsupported-source"
|
||||
| "host-not-running"
|
||||
| "nonlocal-host"
|
||||
| "password-protected"
|
||||
| "host-version-mismatch"
|
||||
| "unavailable"
|
||||
| null;
|
||||
}>,
|
||||
run: (input: { source: string }) =>
|
||||
ipcRenderer.invoke("paseo:imports:run", input) as Promise<{ runId: string }>,
|
||||
onOutput: (handler: (output: DesktopImportOutput) => void): (() => void) => {
|
||||
|
||||
Reference in New Issue
Block a user