chore: finalize electron desktop migration

This commit is contained in:
Mohamed Boudra
2026-03-21 01:50:00 +07:00
parent 77fb74c188
commit 111576e2ca
169 changed files with 6463 additions and 13191 deletions
+15 -4
View File
@@ -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 = {
+15 -32
View File
@@ -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,
};
+117
View File
@@ -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;
};
}
+2 -2
View File
@@ -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;
+81 -17
View File
@@ -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" },
});
});
});
+54 -81
View File
@@ -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;
}
}
+2 -3
View File
@@ -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
};
}
-178
View File
@@ -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,
};
}
-106
View File
@@ -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,