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:
Mohamed Boudra
2026-07-23 20:36:22 +02:00
parent 81bffbd01a
commit a2e8a44848
20 changed files with 179 additions and 209 deletions
@@ -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 () => {
-11
View File
@@ -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;
}
+115 -4
View File
@@ -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,
-5
View File
@@ -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: "الاستيراد عبر تطبيق سطح المكتب غير متاح.",
-5
View File
@@ -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.",
-6
View File
@@ -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.",
-6
View File
@@ -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.",
-6
View File
@@ -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: "デスクトップインポートは利用できません。",
-6
View File
@@ -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.",
-5
View File
@@ -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: "Импорт в настольном приложении недоступен.",
-5
View File
@@ -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;
}
-12
View File
@@ -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) => {