chore: finalize electron desktop migration
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const desktopHostState = {
|
||||
api: null as {
|
||||
dialog?: {
|
||||
ask?: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||
};
|
||||
} | null,
|
||||
};
|
||||
|
||||
type MockPlatform = "web" | "ios" | "android";
|
||||
|
||||
type AlertButton = {
|
||||
@@ -19,13 +27,16 @@ async function loadModuleForPlatform(platform: MockPlatform): Promise<{
|
||||
},
|
||||
Platform: { OS: platform },
|
||||
}));
|
||||
vi.doMock("@/desktop/host", () => ({
|
||||
getDesktopHost: () => desktopHostState.api,
|
||||
}));
|
||||
|
||||
const module = await import("./confirm-dialog");
|
||||
return { confirmDialog: module.confirmDialog, alertMock };
|
||||
}
|
||||
|
||||
function clearDialogGlobals(): void {
|
||||
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||
desktopHostState.api = null;
|
||||
delete (globalThis as { confirm?: unknown }).confirm;
|
||||
}
|
||||
|
||||
@@ -37,13 +48,13 @@ describe("confirmDialog", () => {
|
||||
clearDialogGlobals();
|
||||
});
|
||||
|
||||
it("uses Tauri dialog.ask on web when available", async () => {
|
||||
it("uses the desktop dialog bridge on web when available", async () => {
|
||||
const askMock = vi.fn(async () => true);
|
||||
const blurMock = vi.fn();
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
activeElement: { blur: blurMock },
|
||||
} as unknown as Document;
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
desktopHostState.api = {
|
||||
dialog: { ask: askMock },
|
||||
};
|
||||
|
||||
@@ -67,7 +78,7 @@ describe("confirmDialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
|
||||
it("falls back to browser confirm on web when desktop APIs are unavailable", async () => {
|
||||
const browserConfirm = vi.fn(() => true);
|
||||
const blurMock = vi.fn();
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
|
||||
import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
|
||||
|
||||
export interface ConfirmDialogInput {
|
||||
title: string;
|
||||
@@ -48,14 +48,14 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
|
||||
});
|
||||
}
|
||||
|
||||
function getTauriApi() {
|
||||
function getDesktopApi() {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
return getTauri();
|
||||
return getDesktopHost();
|
||||
}
|
||||
|
||||
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
||||
function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOptions {
|
||||
const labels = resolveButtonLabels(input);
|
||||
|
||||
return {
|
||||
@@ -74,35 +74,18 @@ function blurActiveWebElement(): void {
|
||||
(activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
|
||||
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||
const tauriApi = getTauriApi();
|
||||
if (!tauriApi) {
|
||||
async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||
const desktopApi = getDesktopApi();
|
||||
if (!desktopApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
blurActiveWebElement();
|
||||
const options = buildTauriAskOptions(input);
|
||||
const tauriAsk = tauriApi.dialog?.ask;
|
||||
const options = buildDesktopAskOptions(input);
|
||||
const desktopAsk = desktopApi.dialog?.ask;
|
||||
|
||||
if (typeof tauriAsk === "function") {
|
||||
try {
|
||||
return Boolean(await tauriAsk(input.message, options));
|
||||
} catch (error) {
|
||||
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
const tauriInvoke = tauriApi.core?.invoke;
|
||||
if (typeof tauriInvoke === "function") {
|
||||
try {
|
||||
const result = await tauriInvoke("plugin:dialog|ask", {
|
||||
message: input.message,
|
||||
...options,
|
||||
});
|
||||
return result === true;
|
||||
} catch (error) {
|
||||
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
|
||||
}
|
||||
if (typeof desktopAsk === "function") {
|
||||
return Boolean(await desktopAsk(input.message, options));
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -124,9 +107,9 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
|
||||
return showNativeConfirmDialog(input);
|
||||
}
|
||||
|
||||
const tauriResult = await showTauriConfirmDialog(input);
|
||||
if (tauriResult !== null) {
|
||||
return tauriResult;
|
||||
const desktopResult = await showDesktopConfirmDialog(input);
|
||||
if (desktopResult !== null) {
|
||||
return desktopResult;
|
||||
}
|
||||
|
||||
return showWebConfirmDialog(input);
|
||||
@@ -134,5 +117,5 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
|
||||
|
||||
export const __private__ = {
|
||||
blurActiveWebElement,
|
||||
buildTauriAskOptions,
|
||||
buildDesktopAskOptions,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Platform, type ViewStyle } from "react-native";
|
||||
import {
|
||||
getIsDesktopMac,
|
||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
|
||||
type DesktopDragHandlers = {
|
||||
style?: CSSProperties & ViewStyle;
|
||||
};
|
||||
|
||||
const DESKTOP_DRAG_REGION_STYLE = {
|
||||
WebkitAppRegion: "drag",
|
||||
} as CSSProperties & ViewStyle;
|
||||
|
||||
export async function startDragging() {
|
||||
const win = getDesktopWindow();
|
||||
if (win && typeof win.startDragging === "function") {
|
||||
try {
|
||||
await win.startDragging();
|
||||
} catch (error) {
|
||||
console.warn("[DesktopWindow] startDragging failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDesktopDragHandlers(): DesktopDragHandlers {
|
||||
if (Platform.OS !== "web" || !isDesktop()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
style: DESKTOP_DRAG_REGION_STYLE,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function runCleanup() {
|
||||
if (!cleanup || didCleanup) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanup()).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to remove resize listener", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[DesktopWindow] Failed to remove resize listener", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const win = getDesktopWindow();
|
||||
if (!win) return;
|
||||
|
||||
const fullscreen =
|
||||
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fullscreen);
|
||||
|
||||
if (typeof win.onResized !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const unlisten = await win.onResized(async () => {
|
||||
if (disposed) return;
|
||||
const fs =
|
||||
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fs);
|
||||
});
|
||||
|
||||
cleanup = unlisten;
|
||||
if (disposed) {
|
||||
runCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!getIsDesktopMac() || isFullscreen) {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const managedRuntimeMock = vi.hoisted(() => {
|
||||
let eventHandler: ((payload: {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
text?: string | null;
|
||||
binaryBase64?: string | null;
|
||||
code?: number | null;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
}) => void) | null = null;
|
||||
|
||||
const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise<string>>();
|
||||
const listenToLocalTransportEvents = vi.fn(async (handler: typeof eventHandler extends ((...args: infer A) => any) | null ? (...args: A) => void : never) => {
|
||||
eventHandler = handler;
|
||||
return () => {
|
||||
eventHandler = null;
|
||||
};
|
||||
});
|
||||
const sendLocalTransportMessage = vi.fn(async () => undefined);
|
||||
const closeLocalTransportSession = vi.fn(async () => undefined);
|
||||
|
||||
return {
|
||||
openLocalTransportSession,
|
||||
listenToLocalTransportEvents,
|
||||
sendLocalTransportMessage,
|
||||
closeLocalTransportSession,
|
||||
emitEvent(payload: {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
text?: string | null;
|
||||
binaryBase64?: string | null;
|
||||
code?: number | null;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
}) {
|
||||
eventHandler?.(payload);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
|
||||
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
|
||||
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
|
||||
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
|
||||
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
|
||||
}));
|
||||
|
||||
describe("managed-tauri-daemon-transport", () => {
|
||||
beforeEach(() => {
|
||||
managedRuntimeMock.openLocalTransportSession.mockReset();
|
||||
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
|
||||
managedRuntimeMock.sendLocalTransportMessage.mockClear();
|
||||
managedRuntimeMock.closeLocalTransportSession.mockClear();
|
||||
});
|
||||
|
||||
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
|
||||
let resolveSession!: (sessionId: string) => void;
|
||||
managedRuntimeMock.openLocalTransportSession.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveSession = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const mod = await import("./managed-tauri-daemon-transport");
|
||||
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({
|
||||
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||
});
|
||||
|
||||
const onOpen = vi.fn();
|
||||
transport.onOpen(onOpen);
|
||||
|
||||
managedRuntimeMock.emitEvent({
|
||||
sessionId: "local-session-1",
|
||||
kind: "open",
|
||||
});
|
||||
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
|
||||
resolveSession("local-session-1");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up late async setup after the transport is closed", async () => {
|
||||
let resolveSession!: (sessionId: string) => void;
|
||||
let resolveListen!: (cleanup: () => void) => void;
|
||||
const cleanup = vi.fn();
|
||||
|
||||
managedRuntimeMock.openLocalTransportSession.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveSession = resolve;
|
||||
})
|
||||
);
|
||||
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
|
||||
() =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
resolveListen = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const mod = await import("./managed-tauri-daemon-transport");
|
||||
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({
|
||||
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||
});
|
||||
|
||||
transport.close();
|
||||
|
||||
resolveSession("local-session-2");
|
||||
resolveListen(cleanup);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
||||
import {
|
||||
closeLocalTransportSession,
|
||||
listenToLocalTransportEvents,
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
type LocalTransportTarget,
|
||||
} from "@/desktop/managed-runtime/managed-runtime";
|
||||
|
||||
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
||||
|
||||
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]!);
|
||||
}
|
||||
return globalThis.btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBase64ToBytes(base64: string): Uint8Array {
|
||||
const binary = globalThis.atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
|
||||
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
|
||||
url.searchParams.set("path", target.transportPath);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
|
||||
throw new Error(`Unsupported local transport URL: ${url}`);
|
||||
}
|
||||
const transportType = parsed.hostname;
|
||||
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
|
||||
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
|
||||
throw new Error(`Invalid local transport target: ${url}`);
|
||||
}
|
||||
return {
|
||||
transportType,
|
||||
transportPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
|
||||
return ({ url }) => {
|
||||
const target = parseLocalDaemonTransportUrl(url);
|
||||
let sessionId: string | null = null;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
let didEmitOpen = false;
|
||||
|
||||
const openHandlers = new Set<() => void>();
|
||||
const closeHandlers = new Set<(event?: unknown) => void>();
|
||||
const errorHandlers = new Set<(event?: unknown) => void>();
|
||||
const messageHandlers = new Set<(data: unknown) => void>();
|
||||
|
||||
const emitOpen = () => {
|
||||
if (didEmitOpen || disposed) {
|
||||
return;
|
||||
}
|
||||
didEmitOpen = true;
|
||||
for (const handler of openHandlers) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
const emitClose = (event?: unknown) => {
|
||||
for (const handler of closeHandlers) {
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
const emitError = (event?: unknown) => {
|
||||
for (const handler of errorHandlers) {
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
const emitMessage = (data: unknown) => {
|
||||
for (const handler of messageHandlers) {
|
||||
handler(data);
|
||||
}
|
||||
};
|
||||
|
||||
void listenToLocalTransportEvents((payload) => {
|
||||
if (disposed || !sessionId || payload.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "open") {
|
||||
emitOpen();
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "message") {
|
||||
if (payload.text) {
|
||||
emitMessage({ data: payload.text });
|
||||
return;
|
||||
}
|
||||
if (payload.binaryBase64) {
|
||||
emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "close") {
|
||||
emitClose(payload);
|
||||
return;
|
||||
}
|
||||
emitError(payload.error ?? "Local daemon transport error");
|
||||
})
|
||||
.then((cleanup) => {
|
||||
if (disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
unlisten = cleanup;
|
||||
})
|
||||
.catch((error) => {
|
||||
emitError(error);
|
||||
});
|
||||
|
||||
void openLocalTransportSession(target)
|
||||
.then((id) => {
|
||||
if (disposed) {
|
||||
void closeLocalTransportSession(id).catch((error) => emitError(error));
|
||||
return;
|
||||
}
|
||||
sessionId = id;
|
||||
emitOpen();
|
||||
})
|
||||
.catch((error) => {
|
||||
emitError(error);
|
||||
});
|
||||
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
void sendLocalTransportMessage({ sessionId, text: data }).catch((error) =>
|
||||
emitError(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const binaryBase64 = encodeBinaryToBase64(
|
||||
data instanceof ArrayBuffer ? data : new Uint8Array(data)
|
||||
);
|
||||
void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) =>
|
||||
emitError(error)
|
||||
);
|
||||
},
|
||||
close: () => {
|
||||
disposed = true;
|
||||
const currentSessionId = sessionId;
|
||||
sessionId = null;
|
||||
if (currentSessionId) {
|
||||
void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error));
|
||||
}
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
},
|
||||
onMessage: (handler) => {
|
||||
messageHandlers.add(handler);
|
||||
return () => messageHandlers.delete(handler);
|
||||
},
|
||||
onOpen: (handler) => {
|
||||
openHandlers.add(handler);
|
||||
return () => openHandlers.delete(handler);
|
||||
},
|
||||
onClose: (handler) => {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
onError: (handler) => {
|
||||
errorHandlers.add(handler);
|
||||
return () => errorHandlers.delete(handler);
|
||||
},
|
||||
};
|
||||
|
||||
return transport;
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform } from "react-native";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
const opener = getTauri()?.opener?.openUrl;
|
||||
const opener = getDesktopHost()?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
type MockNotificationOptions = {
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
type MockNotificationInstance = {
|
||||
@@ -18,7 +19,6 @@ type GlobalSnapshot = {
|
||||
dispatchEvent: unknown;
|
||||
focus: unknown;
|
||||
location: unknown;
|
||||
__TAURI__: unknown;
|
||||
};
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
@@ -27,12 +27,34 @@ const originalGlobals: GlobalSnapshot = {
|
||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||
focus: (globalThis as { focus?: unknown }).focus,
|
||||
location: (globalThis as { location?: unknown }).location,
|
||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
||||
async function loadModuleForPlatform(
|
||||
platform: "web" | "ios" | "android",
|
||||
options?: {
|
||||
desktopHost?: {
|
||||
notification?: {
|
||||
sendNotification?: (payload: {
|
||||
title: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
) {
|
||||
vi.resetModules();
|
||||
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
|
||||
vi.doMock("@/desktop/host", () => ({
|
||||
getDesktopHost: () => options?.desktopHost ?? null,
|
||||
}));
|
||||
vi.doMock("expo-asset", () => ({
|
||||
Asset: {
|
||||
fromModule: vi.fn(() => ({
|
||||
uri: "http://localhost:8081/packages/app/assets/images/notification-icon.png",
|
||||
})),
|
||||
},
|
||||
}));
|
||||
return import("./os-notifications");
|
||||
}
|
||||
|
||||
@@ -42,7 +64,6 @@ function restoreGlobals(): void {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
|
||||
}
|
||||
|
||||
describe("sendOsNotification", () => {
|
||||
@@ -71,7 +92,6 @@ describe("sendOsNotification", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("@/desktop/notifications/desktop-notifications");
|
||||
vi.doUnmock("react-native");
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
@@ -168,14 +188,8 @@ describe("sendOsNotification", () => {
|
||||
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
||||
});
|
||||
|
||||
it("uses the desktop notification bridge when Tauri is available", async () => {
|
||||
const sendDesktopNotification = vi.fn(async () => true);
|
||||
vi.doMock("@/desktop/notifications/desktop-notifications", () => ({
|
||||
sendDesktopNotification,
|
||||
}));
|
||||
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {};
|
||||
|
||||
it("returns false when the Notification API is unavailable", async () => {
|
||||
(globalThis as { Notification?: unknown }).Notification = undefined;
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
@@ -183,11 +197,61 @@ describe("sendOsNotification", () => {
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(false);
|
||||
});
|
||||
|
||||
it("does not attach a click handler when there is no route target", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
static permission = "granted";
|
||||
static requestPermission = vi.fn(async () => "granted");
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as { Notification?: unknown }).Notification = MockNotification;
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = vi.fn();
|
||||
(globalThis as { location?: unknown }).location = { assign: vi.fn() };
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(sendDesktopNotification).toHaveBeenCalledWith({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]?.onclick).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the desktop notification bridge when available", async () => {
|
||||
const sendNotification = vi.fn(async () => true);
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web", {
|
||||
desktopHost: {
|
||||
notification: {
|
||||
sendNotification,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
data: { serverId: "srv-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(sendNotification).toHaveBeenCalledWith({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
data: { serverId: "srv-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Asset } from "expo-asset";
|
||||
import { Platform } from "react-native";
|
||||
import { sendDesktopNotification } from "@/desktop/notifications/desktop-notifications";
|
||||
import { buildNotificationRoute } from "./notification-routing";
|
||||
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import {
|
||||
buildNotificationRoute,
|
||||
resolveNotificationTarget,
|
||||
} from "./notification-routing";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -15,28 +18,41 @@ export type WebNotificationClickDetail = {
|
||||
|
||||
type WebNotificationInstance = {
|
||||
onclick?: ((event: Event) => void) | null;
|
||||
onclose?: ((event: Event) => void) | null;
|
||||
addEventListener?: (type: string, listener: (event: Event) => void) => void;
|
||||
removeEventListener?: (type: string, listener: (event: Event) => void) => void;
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
const activeWebNotifications = new Set<WebNotificationInstance>();
|
||||
let notificationIconUrl: string | null | undefined;
|
||||
|
||||
function getTauriNotificationModule(): TauriNotificationApi | null {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
return getTauri()?.notification ?? null;
|
||||
function getDesktopNotificationSender():
|
||||
| ((payload: {
|
||||
title: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) => Promise<boolean>)
|
||||
| null {
|
||||
const sendNotification = getDesktopHost()?.notification?.sendNotification;
|
||||
return typeof sendNotification === "function"
|
||||
? (sendNotification as (payload: {
|
||||
title: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) => Promise<boolean>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getWebNotificationConstructor(): {
|
||||
permission: string;
|
||||
requestPermission?: () => Promise<string>;
|
||||
new (title: string, options?: { body?: string; data?: Record<string, unknown> }): unknown;
|
||||
new (
|
||||
title: string,
|
||||
options?: {
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
}
|
||||
): unknown;
|
||||
} | null {
|
||||
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
|
||||
return NotificationConstructor ?? null;
|
||||
@@ -70,49 +86,31 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
if (Platform.OS !== "web") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriNotification = getTauriNotificationModule();
|
||||
if (tauriNotification) {
|
||||
return await ensureTauriNotificationPermission(tauriNotification);
|
||||
}
|
||||
|
||||
return await ensureNotificationPermission();
|
||||
}
|
||||
|
||||
async function ensureTauriNotificationPermission(
|
||||
notificationModule: TauriNotificationApi
|
||||
): Promise<boolean> {
|
||||
if (typeof notificationModule.isPermissionGranted === "function") {
|
||||
try {
|
||||
const granted = await notificationModule.isPermissionGranted();
|
||||
if (granted) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] Failed to check notification permission",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
function hasNotificationClickTarget(
|
||||
data: Record<string, unknown> | undefined
|
||||
): boolean {
|
||||
const target = resolveNotificationTarget(data);
|
||||
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
|
||||
}
|
||||
|
||||
if (typeof notificationModule.requestPermission !== "function") {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] notification.requestPermission is unavailable"
|
||||
);
|
||||
return false;
|
||||
function getWebNotificationIconUrl(): string | undefined {
|
||||
if (notificationIconUrl !== undefined) {
|
||||
return notificationIconUrl ?? undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await notificationModule.requestPermission();
|
||||
return result === "granted";
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] Failed to request notification permission",
|
||||
error
|
||||
const asset = Asset.fromModule(
|
||||
require("../../assets/images/notification-icon.png")
|
||||
);
|
||||
return false;
|
||||
notificationIconUrl = asset.uri ?? null;
|
||||
} catch {
|
||||
notificationIconUrl = null;
|
||||
}
|
||||
|
||||
return notificationIconUrl ?? undefined;
|
||||
}
|
||||
|
||||
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||
@@ -156,41 +154,12 @@ function attachWebClickHandler(
|
||||
notification: WebNotificationInstance,
|
||||
data: Record<string, unknown> | undefined
|
||||
): void {
|
||||
activeWebNotifications.add(notification);
|
||||
|
||||
const cleanup = () => {
|
||||
activeWebNotifications.delete(notification);
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
const focus = (globalThis as { focus?: () => void }).focus;
|
||||
if (typeof focus === "function") {
|
||||
focus();
|
||||
}
|
||||
|
||||
notification.onclick = () => {
|
||||
const handledByApp = dispatchWebNotificationClick({ data });
|
||||
if (!handledByApp) {
|
||||
fallbackNavigateToNotificationTarget(data);
|
||||
}
|
||||
|
||||
cleanup();
|
||||
if (typeof notification.close === "function") {
|
||||
notification.close();
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
if (typeof notification.addEventListener === "function") {
|
||||
notification.addEventListener("click", onClick);
|
||||
notification.addEventListener("close", onClose);
|
||||
return;
|
||||
}
|
||||
|
||||
notification.onclick = onClick;
|
||||
notification.onclose = onClose;
|
||||
}
|
||||
|
||||
export async function sendOsNotification(
|
||||
@@ -201,8 +170,9 @@ export async function sendOsNotification(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getTauri()) {
|
||||
return await sendDesktopNotification(payload);
|
||||
const desktopNotificationSender = getDesktopNotificationSender();
|
||||
if (desktopNotificationSender) {
|
||||
return await desktopNotificationSender(payload);
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor();
|
||||
@@ -212,8 +182,11 @@ export async function sendOsNotification(
|
||||
const notification = new NotificationConstructor(payload.title, {
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
icon: getWebNotificationIconUrl(),
|
||||
}) as WebNotificationInstance;
|
||||
attachWebClickHandler(notification, payload.data);
|
||||
if (hasNotificationClickTarget(payload.data)) {
|
||||
attachWebClickHandler(notification, payload.data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { getIsDesktopMac } from "@/constants/layout";
|
||||
import type { ShortcutOs } from "@/utils/format-shortcut";
|
||||
|
||||
export function getShortcutOs(): ShortcutOs {
|
||||
if (Platform.OS !== "web") {
|
||||
return Platform.OS === "ios" ? "mac" : "non-mac";
|
||||
}
|
||||
if (getIsTauriMac()) return "mac";
|
||||
if (getIsDesktopMac()) return "mac";
|
||||
if (typeof navigator === "undefined") return "non-mac";
|
||||
const ua = navigator.userAgent ?? "";
|
||||
const platform = (navigator as any).platform ?? "";
|
||||
@@ -15,4 +15,3 @@ export function getShortcutOs(): ShortcutOs {
|
||||
/Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
return isApple ? "mac" : "non-mac";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
let attached = false;
|
||||
|
||||
export function attachConsole(): void {
|
||||
if (attached || !getTauri()) {
|
||||
return;
|
||||
}
|
||||
attached = true;
|
||||
|
||||
const originalLog = console.log;
|
||||
const originalInfo = console.info;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
const originalDebug = console.debug;
|
||||
|
||||
function formatArgs(args: unknown[]): string {
|
||||
return args
|
||||
.map((a) =>
|
||||
typeof a === "string" ? a : JSON.stringify(a, null, 0) ?? String(a)
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function forwardToRust(level: number, args: unknown[]): void {
|
||||
const invoke = getTauri()?.core?.invoke;
|
||||
if (typeof invoke !== "function") return;
|
||||
try {
|
||||
void invoke("webview_log", { level, message: formatArgs(args) });
|
||||
} catch {
|
||||
// silently ignore IPC errors
|
||||
}
|
||||
}
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
originalLog.apply(console, args);
|
||||
forwardToRust(1, args); // info
|
||||
};
|
||||
console.info = (...args: unknown[]) => {
|
||||
originalInfo.apply(console, args);
|
||||
forwardToRust(1, args); // info
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
originalWarn.apply(console, args);
|
||||
forwardToRust(2, args); // warn
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
originalError.apply(console, args);
|
||||
forwardToRust(3, args); // error
|
||||
};
|
||||
console.debug = (...args: unknown[]) => {
|
||||
originalDebug.apply(console, args);
|
||||
forwardToRust(0, args); // debug
|
||||
};
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
|
||||
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||
|
||||
let tauriWindow: any = null;
|
||||
const NON_DRAGGABLE_SELECTOR = [
|
||||
"button",
|
||||
"a",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"[role='button']",
|
||||
"[role='link']",
|
||||
"[role='textbox']",
|
||||
"[role='combobox']",
|
||||
"[contenteditable='true']",
|
||||
].join(", ");
|
||||
|
||||
async function getTauriWindow() {
|
||||
if (tauriWindow) return tauriWindow;
|
||||
|
||||
// Double-check: both environment check AND platform check
|
||||
if (!isTauriEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs via getTauri().
|
||||
// We must use that here because importing `@tauri-apps/api/*` would be bundled into
|
||||
// the native (Hermes) builds and break parsing/runtime on mobile.
|
||||
const tauri = getTauri();
|
||||
if (!tauri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the public global window module.
|
||||
const publicWindow = getCurrentTauriWindow();
|
||||
if (publicWindow) {
|
||||
tauriWindow = publicWindow;
|
||||
return tauriWindow;
|
||||
}
|
||||
|
||||
// Fallback: core invoke (should exist when withGlobalTauri is enabled).
|
||||
// We assume the main window label is "main" (default in Tauri when not specified).
|
||||
const invoke = tauri?.core?.invoke;
|
||||
if (typeof invoke === "function") {
|
||||
tauriWindow = {
|
||||
label: "main",
|
||||
startDragging: () => invoke("plugin:window|start_dragging", { label: "main" }),
|
||||
toggleMaximize: () => invoke("plugin:window|toggle_maximize", { label: "main" }),
|
||||
isFullscreen: () => invoke("plugin:window|is_fullscreen", { label: "main" }),
|
||||
onResized: (handler: (e: unknown) => void) => tauri?.event?.listen?.("tauri://resize", handler),
|
||||
};
|
||||
return tauriWindow;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDragging() {
|
||||
const win = await getTauriWindow();
|
||||
if (win) {
|
||||
try {
|
||||
await win.startDragging();
|
||||
} catch (error) {
|
||||
console.warn("[TauriWindow] startDragging failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMaximize() {
|
||||
const win = await getTauriWindow();
|
||||
if (win) {
|
||||
try {
|
||||
await win.toggleMaximize();
|
||||
} catch (error) {
|
||||
console.warn("[TauriWindow] toggleMaximize failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns event handlers for drag region behavior
|
||||
export function useTauriDragHandlers() {
|
||||
// Dragging should work on any desktop OS when running under Tauri.
|
||||
if (Platform.OS !== "web" || !isTauriEnvironment()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
onMouseDown: (e: React.MouseEvent) => {
|
||||
// Only handle primary button, ignore if clicking on interactive elements.
|
||||
// Tauri docs recommend using `e.detail` on mousedown for double-click maximize.
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target instanceof Element ? e.target : null;
|
||||
if (target?.closest(NON_DRAGGABLE_SELECTOR)) return;
|
||||
|
||||
// Prevent text selection when dragging
|
||||
e.preventDefault();
|
||||
|
||||
// Double click to maximize, otherwise drag.
|
||||
if (e.detail === 2) {
|
||||
toggleMaximize();
|
||||
} else {
|
||||
startDragging();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Hook that returns traffic light padding, accounting for fullscreen state
|
||||
// In fullscreen, traffic lights are hidden so no padding is needed
|
||||
export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function runCleanup() {
|
||||
if (!cleanup || didCleanup) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanup()).catch((error) => {
|
||||
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const win = await getTauriWindow();
|
||||
if (!win) return;
|
||||
|
||||
// Check initial fullscreen state
|
||||
const fullscreen = await win.isFullscreen();
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fullscreen);
|
||||
|
||||
// Listen for resize events which include fullscreen changes
|
||||
const unlisten = await win.onResized(async () => {
|
||||
if (disposed) return;
|
||||
const fs = await win.isFullscreen();
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fs);
|
||||
});
|
||||
|
||||
cleanup = unlisten;
|
||||
if (disposed) {
|
||||
runCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!getIsTauriMac() || isFullscreen) {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
left: TAURI_TRAFFIC_LIGHT_WIDTH,
|
||||
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
export type TauriNotificationPermission = "granted" | "denied" | "default";
|
||||
|
||||
export interface TauriDialogAskOptions {
|
||||
title?: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
kind?: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
export interface TauriDialogOpenOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
directory?: boolean;
|
||||
multiple?: boolean;
|
||||
filters?: Array<{
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TauriDialogApi {
|
||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||
open?: (
|
||||
options?: TauriDialogOpenOptions
|
||||
) => Promise<string | string[] | null>;
|
||||
}
|
||||
|
||||
export interface TauriCoreApi {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
convertFileSrc?: (path: string) => string;
|
||||
}
|
||||
|
||||
export interface TauriEventApi {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (event: unknown) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
}
|
||||
|
||||
export interface TauriWindowApi {
|
||||
label?: string;
|
||||
startDragging?: () => Promise<void>;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
setBadgeCount?: (count?: number) => Promise<void>;
|
||||
onDragDropEvent?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
}
|
||||
|
||||
export interface TauriWindowModule {
|
||||
getCurrentWindow?: () => TauriWindowApi;
|
||||
}
|
||||
|
||||
export interface TauriOpenerApi {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TauriNotificationApi {
|
||||
isPermissionGranted?: () => Promise<boolean>;
|
||||
requestPermission?: () => Promise<TauriNotificationPermission>;
|
||||
sendNotification?: (
|
||||
payload: string | { title: string; body?: string }
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TauriWebSocketApi {
|
||||
connect?: (url: string, config?: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface TauriApi {
|
||||
core?: TauriCoreApi;
|
||||
dialog?: TauriDialogApi;
|
||||
event?: TauriEventApi;
|
||||
notification?: TauriNotificationApi;
|
||||
opener?: TauriOpenerApi;
|
||||
websocket?: TauriWebSocketApi;
|
||||
window?: TauriWindowModule;
|
||||
}
|
||||
|
||||
export function getTauri(): TauriApi | null {
|
||||
const tauri = (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||
if (!tauri || typeof tauri !== "object") {
|
||||
return null;
|
||||
}
|
||||
return tauri as TauriApi;
|
||||
}
|
||||
|
||||
export function isTauriEnvironment(): boolean {
|
||||
return getTauri() !== null;
|
||||
}
|
||||
|
||||
export function getCurrentTauriWindow(): TauriWindowApi | null {
|
||||
const getter = getTauri()?.window?.getCurrentWindow;
|
||||
if (typeof getter !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return getter() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { getOrCreateClientId } from "./client-id";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
||||
import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "./managed-tauri-daemon-transport";
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -49,7 +49,7 @@ export async function buildClientConfig(
|
||||
serverId?: string
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
|
||||
Reference in New Issue
Block a user