diff --git a/Cargo.lock b/Cargo.lock index 777d87841..7652aa1d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13911,6 +13911,10 @@ dependencies = [ name = "tauri-plugin-windows" version = "0.1.0" dependencies = [ + "objc2 0.6.1", + "objc2-app-kit 0.3.1", + "objc2-foundation 0.3.1", + "once_cell", "serde", "specta", "specta-typescript", @@ -13922,6 +13926,7 @@ dependencies = [ "tauri-plugin-auth", "tauri-specta", "thiserror 2.0.12", + "tokio", "tracing", "uuid", ] diff --git a/apps/desktop/index.html b/apps/desktop/index.html index bd431afc4..9519f07fe 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -8,6 +8,12 @@ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> Hyprnote +
diff --git a/apps/desktop/src/components/editor-area/note-header/listen-button.tsx b/apps/desktop/src/components/editor-area/note-header/listen-button.tsx index 45468d03c..4cf15c07d 100644 --- a/apps/desktop/src/components/editor-area/note-header/listen-button.tsx +++ b/apps/desktop/src/components/editor-area/note-header/listen-button.tsx @@ -197,8 +197,6 @@ function WhenActive() { pause: s.pause, stop: s.stop, })); - const audioControls = useAudioControls(); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); const handlePauseSession = () => { @@ -226,7 +224,6 @@ function WhenActive() { @@ -236,25 +233,36 @@ function WhenActive() { } function RecordingControls({ - audioControls, onPause, onStop, }: { - audioControls: ReturnType; onPause: () => void; onStop: () => void; }) { + const ongoingSessionMuted = useOngoingSession((s) => ({ + micMuted: s.micMuted, + speakerMuted: s.speakerMuted, + })); + + const toggleMicMuted = useMutation({ + mutationFn: () => listenerCommands.setMicMuted(!ongoingSessionMuted.micMuted), + }); + + const toggleSpeakerMuted = useMutation({ + mutationFn: () => listenerCommands.setSpeakerMuted(!ongoingSessionMuted.speakerMuted), + }); + return ( <>
audioControls.toggleMicMuted.mutate()} + isMuted={ongoingSessionMuted.micMuted} + onClick={() => toggleMicMuted.mutate()} type="mic" /> audioControls.toggleSpeakerMuted.mutate()} + isMuted={ongoingSessionMuted.speakerMuted} + onClick={() => toggleSpeakerMuted.mutate()} type="speaker" />
@@ -313,34 +321,3 @@ function AudioControlButton({ ); } - -function useAudioControls() { - const { data: isMicMuted, refetch: refetchMicMuted } = useQuery({ - queryKey: ["mic-muted"], - queryFn: () => listenerCommands.getMicMuted(), - }); - - const { data: isSpeakerMuted, refetch: refetchSpeakerMuted } = useQuery({ - queryKey: ["speaker-muted"], - queryFn: () => listenerCommands.getSpeakerMuted(), - }); - - const toggleMicMuted = useMutation({ - mutationFn: () => listenerCommands.setMicMuted(!isMicMuted), - onSuccess: () => refetchMicMuted(), - }); - - const toggleSpeakerMuted = useMutation({ - mutationFn: () => listenerCommands.setSpeakerMuted(!isSpeakerMuted), - onSuccess: () => refetchSpeakerMuted(), - }); - - return { - isMicMuted, - isSpeakerMuted, - toggleMicMuted, - toggleSpeakerMuted, - refetchSpeakerMuted, - refetchMicMuted, - }; -} diff --git a/apps/desktop/src/routeTree.gen.ts b/apps/desktop/src/routeTree.gen.ts index 4c9dddbd9..b210add37 100644 --- a/apps/desktop/src/routeTree.gen.ts +++ b/apps/desktop/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as AppIndexImport } from './routes/app.index' import { Route as AppSettingsImport } from './routes/app.settings' import { Route as AppPlansImport } from './routes/app.plans' import { Route as AppNewImport } from './routes/app.new' +import { Route as AppControlImport } from './routes/app.control' import { Route as AppCalendarImport } from './routes/app.calendar' import { Route as AppOrganizationIdImport } from './routes/app.organization.$id' import { Route as AppNoteIdImport } from './routes/app.note.$id' @@ -61,6 +62,12 @@ const AppNewRoute = AppNewImport.update({ getParentRoute: () => AppRoute, } as any) +const AppControlRoute = AppControlImport.update({ + id: '/control', + path: '/control', + getParentRoute: () => AppRoute, +} as any) + const AppCalendarRoute = AppCalendarImport.update({ id: '/calendar', path: '/calendar', @@ -116,6 +123,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppCalendarImport parentRoute: typeof AppImport } + '/app/control': { + id: '/app/control' + path: '/control' + fullPath: '/app/control' + preLoaderRoute: typeof AppControlImport + parentRoute: typeof AppImport + } '/app/new': { id: '/app/new' path: '/new' @@ -179,6 +193,7 @@ declare module '@tanstack/react-router' { interface AppRouteChildren { AppCalendarRoute: typeof AppCalendarRoute + AppControlRoute: typeof AppControlRoute AppNewRoute: typeof AppNewRoute AppPlansRoute: typeof AppPlansRoute AppSettingsRoute: typeof AppSettingsRoute @@ -191,6 +206,7 @@ interface AppRouteChildren { const AppRouteChildren: AppRouteChildren = { AppCalendarRoute: AppCalendarRoute, + AppControlRoute: AppControlRoute, AppNewRoute: AppNewRoute, AppPlansRoute: AppPlansRoute, AppSettingsRoute: AppSettingsRoute, @@ -207,6 +223,7 @@ export interface FileRoutesByFullPath { '/app': typeof AppRouteWithChildren '/video': typeof VideoRoute '/app/calendar': typeof AppCalendarRoute + '/app/control': typeof AppControlRoute '/app/new': typeof AppNewRoute '/app/plans': typeof AppPlansRoute '/app/settings': typeof AppSettingsRoute @@ -220,6 +237,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/video': typeof VideoRoute '/app/calendar': typeof AppCalendarRoute + '/app/control': typeof AppControlRoute '/app/new': typeof AppNewRoute '/app/plans': typeof AppPlansRoute '/app/settings': typeof AppSettingsRoute @@ -235,6 +253,7 @@ export interface FileRoutesById { '/app': typeof AppRouteWithChildren '/video': typeof VideoRoute '/app/calendar': typeof AppCalendarRoute + '/app/control': typeof AppControlRoute '/app/new': typeof AppNewRoute '/app/plans': typeof AppPlansRoute '/app/settings': typeof AppSettingsRoute @@ -251,6 +270,7 @@ export interface FileRouteTypes { | '/app' | '/video' | '/app/calendar' + | '/app/control' | '/app/new' | '/app/plans' | '/app/settings' @@ -263,6 +283,7 @@ export interface FileRouteTypes { to: | '/video' | '/app/calendar' + | '/app/control' | '/app/new' | '/app/plans' | '/app/settings' @@ -276,6 +297,7 @@ export interface FileRouteTypes { | '/app' | '/video' | '/app/calendar' + | '/app/control' | '/app/new' | '/app/plans' | '/app/settings' @@ -315,6 +337,7 @@ export const routeTree = rootRoute "filePath": "app.tsx", "children": [ "/app/calendar", + "/app/control", "/app/new", "/app/plans", "/app/settings", @@ -332,6 +355,10 @@ export const routeTree = rootRoute "filePath": "app.calendar.tsx", "parent": "/app" }, + "/app/control": { + "filePath": "app.control.tsx", + "parent": "/app" + }, "/app/new": { "filePath": "app.new.tsx", "parent": "/app" diff --git a/apps/desktop/src/routes/__root.tsx b/apps/desktop/src/routes/__root.tsx index 2db1ab69c..3fdba946c 100644 --- a/apps/desktop/src/routes/__root.tsx +++ b/apps/desktop/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { scan } from "react-scan"; import { useQuery } from "@tanstack/react-query"; import { CatchNotFound, createRootRouteWithContext, Outlet, useNavigate } from "@tanstack/react-router"; +import { listen } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { lazy, Suspense, useEffect } from "react"; @@ -67,6 +68,19 @@ function Component() { scan({ enabled: true }); }, []); + // Listen for debug events from control window + useEffect(() => { + let unlisten: (() => void) | undefined; + + listen("debug", (event) => { + console.log(`[Control Debug] ${event.payload}`); + }).then((fn) => { + unlisten = fn; + }); + + return () => unlisten?.(); + }, []); + return ( diff --git a/apps/desktop/src/routes/app.control.tsx b/apps/desktop/src/routes/app.control.tsx new file mode 100644 index 000000000..19d14012f --- /dev/null +++ b/apps/desktop/src/routes/app.control.tsx @@ -0,0 +1,532 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { emit } from "@tauri-apps/api/event"; +import { Circle, Grip, Mic, MicOff, Square, Volume2, VolumeX } from "lucide-react"; +import React, { useEffect, useRef, useState } from "react"; + +import { commands as listenerCommands, events as listenerEvents } from "@hypr/plugin-listener"; +import { commands as windowsCommands } from "@hypr/plugin-windows"; + +export const Route = createFileRoute("/app/control")({ + component: Component, +}); + +function Component() { + const [position, setPosition] = useState(() => { + const savedPosition = localStorage.getItem("floating-control-position"); + if (savedPosition) { + try { + const parsed = JSON.parse(savedPosition); + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + if ( + parsed.x >= 0 && parsed.x <= windowWidth - 200 + && parsed.y >= 0 && parsed.y <= windowHeight - 100 + ) { + return parsed; + } + } catch (e) { + console.warn("Failed to parse saved position:", e); + } + } + + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const initialX = (windowWidth - 200) / 2; + const initialY = (windowHeight - 200) / 2; + + return { x: initialX, y: initialY }; + }); + + const [isDragging, setIsDragging] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + + // Use refs to store current values for event handlers + const isDraggingRef = useRef(false); + const dragOffsetRef = useRef({ x: 0, y: 0 }); + + // Interaction tracking (lifted to component scope) + const lastInteractionRef = React.useRef(Date.now()); + const trackInteraction = React.useCallback(() => { + lastInteractionRef.current = Date.now(); + }, []); + + // Update refs whenever state changes + useEffect(() => { + isDraggingRef.current = isDragging; + }, [isDragging]); + + useEffect(() => { + dragOffsetRef.current = dragOffset; + }, [dragOffset]); + + // Recording state from listener plugin + const [recordingStatus, setRecordingStatus] = useState<"inactive" | "running_active" | "running_paused">("inactive"); + const [recordingLoading, setRecordingLoading] = useState(false); + + // Audio controls state + const [micMuted, setMicMuted] = useState(false); + const [speakerMuted, setSpeakerMuted] = useState(false); + + const isRecording = recordingStatus !== "inactive"; + const isRecordingActive = recordingStatus === "running_active"; + const isRecordingPaused = recordingStatus === "running_paused"; + + // Load initial recording state and listen for changes + useEffect(() => { + const initializeState = async () => { + try { + // Get initial state from listener plugin + const currentState = await listenerCommands.getState(); + console.log(`[Control Bar] Initial state: ${currentState}`); + + if (currentState === "running_active" || currentState === "running_paused" || currentState === "inactive") { + setRecordingStatus(currentState as any); + } + + // Get initial audio state + const [initialMicMuted, initialSpeakerMuted] = await Promise.all([ + listenerCommands.getMicMuted(), + listenerCommands.getSpeakerMuted(), + ]); + setMicMuted(initialMicMuted); + setSpeakerMuted(initialSpeakerMuted); + } catch (error) { + console.error("[Control Bar] Failed to load initial state:", error); + } + }; + + initializeState(); + + const unsubscribeSession = listenerEvents.sessionEvent.listen(({ payload }) => { + if (payload.type === "inactive" || payload.type === "running_active" || payload.type === "running_paused") { + setRecordingStatus(payload.type); + setRecordingLoading(false); + } + + if (payload.type === "micMuted") { + setMicMuted(payload.value); + } + + if (payload.type === "speakerMuted") { + setSpeakerMuted(payload.value); + } + }); + + return () => { + unsubscribeSession.then(unlisten => unlisten()); + }; + }, []); + + // Debug logging + useEffect(() => { + console.log( + `[Control Bar Debug] Recording status: ${recordingStatus}, isRecording: ${isRecording}, isRecordingActive: ${isRecordingActive}`, + ); + }, [recordingStatus, isRecording, isRecordingActive]); + + const controlRef = useRef(null) as React.MutableRefObject; + const toolbarRef = useRef(null); + const boundsUpdateTimeoutRef = useRef(null); + + const setControlRef = (el: HTMLDivElement | null) => { + if (el) { + setTimeout(() => { + const rect = el.getBoundingClientRect(); + const actualPosition = { x: rect.left, y: rect.top }; + + const threshold = 10; + if ( + Math.abs(actualPosition.x - position.x) > threshold + || Math.abs(actualPosition.y - position.y) > threshold + ) { + setPosition(actualPosition); + } + }, 50); + } + controlRef.current = el; + }; + + const updateOverlayBounds = async () => { + if (toolbarRef.current) { + const toolbarRect = toolbarRef.current.getBoundingClientRect(); + + let bounds = { + x: position.x, + y: position.y, + width: toolbarRect.width, + height: toolbarRect.height, + }; + + emit("debug", `Toolbar position: ${JSON.stringify(position)}`); + emit( + "debug", + `Toolbar rect: ${ + JSON.stringify({ x: toolbarRect.x, y: toolbarRect.y, width: toolbarRect.width, height: toolbarRect.height }) + }`, + ); + emit("debug", `Setting overlay bounds: ${JSON.stringify(bounds)}`); + emit("debug", `Window dimensions: ${JSON.stringify({ width: window.innerWidth, height: window.innerHeight })}`); + + try { + await windowsCommands.setFakeWindowBounds("control", bounds); + } catch (error) { + console.error("Failed to set fake window bounds:", error); + } + } + }; + + // Debounced version to prevent excessive bounds updates + const debouncedUpdateBounds = () => { + if (boundsUpdateTimeoutRef.current) { + clearTimeout(boundsUpdateTimeoutRef.current); + } + boundsUpdateTimeoutRef.current = window.setTimeout(() => { + updateOverlayBounds(); + boundsUpdateTimeoutRef.current = null; + }, 100); // Increased debounce delay to 100ms for better stability + }; + + const handleToolbarClick = (e: React.MouseEvent) => { + // Don't stop propagation to allow drag events to work properly + }; + + useEffect(() => { + // Immediately set transparent background to prevent white flash + document.body.style.background = "transparent"; + document.body.style.backgroundColor = "transparent"; + document.documentElement.style.background = "transparent"; + document.documentElement.style.backgroundColor = "transparent"; + document.documentElement.setAttribute("data-transparent-window", "true"); + + const handleMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current) { + return; + } + + // Get toolbar dimensions for clamping + const toolbarWidth = toolbarRef.current?.getBoundingClientRect().width || 200; + const toolbarHeight = toolbarRef.current?.getBoundingClientRect().height || 60; + + // Clamp position to keep toolbar on screen + const clampedX = Math.max(0, Math.min(window.innerWidth - toolbarWidth, e.clientX - dragOffsetRef.current.x)); + const clampedY = Math.max(0, Math.min(window.innerHeight - toolbarHeight, e.clientY - dragOffsetRef.current.y)); + + const newPosition = { + x: clampedX, + y: clampedY, + }; + + setPosition(newPosition); + }; + + const handleMouseUp = () => { + setIsDragging(false); + // Force bounds update after drag completes + setTimeout(() => { + updateOverlayBounds(); + }, 50); + }; + + // Handle desktop switching and window focus changes on Mac + const handleWindowFocus = () => { + // Smart recovery on focus - only aggressive if needed + smartRecovery(); + }; + + const handleVisibilityChange = () => { + if (!document.hidden) { + // Smart recovery on visibility change + smartRecovery(); + } + }; + + const handleWindowResize = () => { + debouncedUpdateBounds(); + }; + + const smartRecovery = () => { + const timeSinceInteraction = Date.now() - lastInteractionRef.current; + if (timeSinceInteraction > 10000) { // 10 seconds of no interaction + windowsCommands.removeFakeWindow("control").then(() => { + setTimeout(updateOverlayBounds, 100); + }).catch(console.error); + } else { + // Just do a simple bounds update + updateOverlayBounds(); + } + trackInteraction(); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + window.addEventListener("focus", handleWindowFocus); + window.addEventListener("resize", handleWindowResize); + document.addEventListener("visibilitychange", handleVisibilityChange); + + // Initial bounds setup - use longer delay to ensure DOM is ready and position is loaded + setTimeout(() => { + updateOverlayBounds(); + }, 200); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("focus", handleWindowFocus); + window.removeEventListener("resize", handleWindowResize); + document.removeEventListener("visibilitychange", handleVisibilityChange); + if (boundsUpdateTimeoutRef.current) { + clearTimeout(boundsUpdateTimeoutRef.current); + } + windowsCommands.removeFakeWindow("control"); + }; + }, []); // Remove dependencies to prevent re-creating event listeners + + useEffect(() => { + // Update bounds whenever position changes (safety mechanism) + debouncedUpdateBounds(); + + // Save position to localStorage for persistence across window recreations + localStorage.setItem("floating-control-position", JSON.stringify(position)); + }, [position]); + + // Effect to detect and sync to actual rendered position (handles window recreation) + useEffect(() => { + const detectActualPosition = () => { + if (controlRef.current) { + const rect = controlRef.current.getBoundingClientRect(); + const actualPosition = { x: rect.left, y: rect.top }; + + // If there's a significant difference, sync React state to actual position + const threshold = 10; // pixels + if ( + Math.abs(actualPosition.x - position.x) > threshold + || Math.abs(actualPosition.y - position.y) > threshold + ) { + setPosition(actualPosition); + } else { + // Positions match, just update bounds + updateOverlayBounds(); + } + } + }; + + // Multiple attempts to catch the actual position + const timers = [100, 200, 500].map(delay => setTimeout(detectActualPosition, delay)); + + return () => timers.forEach(clearTimeout); + }, []); // Only run once on mount + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsDragging(true); + setDragOffset({ + x: e.clientX - position.x, + y: e.clientY - position.y, + }); + }; + + const toggleRecording = async () => { + try { + setRecordingLoading(true); + + if (isRecording) { + if (isRecordingActive) { + await listenerCommands.stopSession(); + } else if (isRecordingPaused) { + await listenerCommands.resumeSession(); + } + } else { + // Create a new session and start recording + const newSessionId = `control-session-${Date.now()}`; + await listenerCommands.startSession(newSessionId); + } + } catch (error) { + console.error("[Control Bar] Recording error:", error); + } finally { + setRecordingLoading(false); + } + }; + + const pauseRecording = async () => { + try { + setRecordingLoading(true); + if (isRecordingActive) { + await listenerCommands.pauseSession(); + } + } catch (error) { + console.error("[Control Bar] Pause error:", error); + } finally { + setRecordingLoading(false); + } + }; + + const toggleMic = async () => { + try { + const newMuted = !micMuted; + await listenerCommands.setMicMuted(newMuted); + setMicMuted(newMuted); + // Emit event to synchronize with other windows + await emit("audio-mic-state-changed", { muted: newMuted }); + console.log(`[Control Bar] ${newMuted ? "Muted" : "Unmuted"} microphone`); + } catch (error) { + console.error("[Control Bar] Mic toggle error:", error); + } + }; + + const toggleSpeaker = async () => { + try { + const newMuted = !speakerMuted; + await listenerCommands.setSpeakerMuted(newMuted); + setSpeakerMuted(newMuted); + // Emit event to synchronize with other windows + await emit("audio-speaker-state-changed", { muted: newMuted }); + console.log(`[Control Bar] ${newMuted ? "Muted" : "Unmuted"} speaker`); + } catch (error) { + console.error("[Control Bar] Speaker toggle error:", error); + } + }; + + return ( +
+
+
{ + // Lightweight hover recovery + trackInteraction(); + updateOverlayBounds(); + }} + style={{ + pointerEvents: "auto", + background: "rgba(0, 0, 0, 0.85)", + boxShadow: "0 8px 32px 0 rgba(0, 0, 0, 0.6)", + }} + > +
+ {/* Section 1: Mic + Speaker */} +
+ + {micMuted ? : } + + + + {speakerMuted ? : } + +
+ +
+ + {/* Section 2: Pause + Stop */} +
+ {/* Pause/Resume Button */} + {isRecording && ( + + {recordingLoading + ?
+ : isRecordingActive + ? ( +
+
+
+
+ ) + : } + + )} + + {/* Stop/Start Button */} + + {recordingLoading + ?
+ : isRecording + ? + : } + +
+ +
+ +
+
+ +
+
+
+
+
+
+ ); +} + +function IconButton({ onClick, children, className = "", tooltip = "", disabled = false }: { + onClick?: ((e: React.MouseEvent) => void) | (() => void); + children: React.ReactNode; + className?: string; + tooltip?: string; + disabled?: boolean; +}) { + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent button clicks from triggering drag + if (!disabled) { + onClick?.(e); + } + }; + + return ( + + ); +} diff --git a/packages/utils/src/stores/ongoing-session.ts b/packages/utils/src/stores/ongoing-session.ts index 1655fcfb1..34e1b624f 100644 --- a/packages/utils/src/stores/ongoing-session.ts +++ b/packages/utils/src/stores/ongoing-session.ts @@ -11,6 +11,8 @@ type State = { status: "inactive" | "running_active" | "running_paused"; amplitude: { mic: number; speaker: number }; enhanceController: AbortController | null; + micMuted: boolean; + speakerMuted: boolean; }; type Actions = { @@ -29,6 +31,8 @@ const initialState: State = { loading: false, amplitude: { mic: 0, speaker: 0 }, enhanceController: null, + micMuted: false, + speakerMuted: false, }; export type OngoingSessionStore = ReturnType; @@ -71,6 +75,39 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType + mutate(state, (draft) => { + draft.status = "running_active"; + draft.loading = false; + }) + ); + } else if (payload.type === "running_paused") { + set((state) => + mutate(state, (draft) => { + draft.status = "running_paused"; + draft.loading = false; + }) + ); + } else if (payload.type === "inactive") { + set((state) => + mutate(state, (draft) => { + draft.status = "inactive"; + draft.loading = false; + }) + ); + } else if (payload.type === "micMuted") { + set((state) => + mutate(state, (draft) => { + draft.micMuted = payload.value; + }) + ); + } else if (payload.type === "speakerMuted") { + set((state) => + mutate(state, (draft) => { + draft.speakerMuted = payload.value; + }) + ); } }).then((unlisten) => { set((state) => @@ -90,6 +127,12 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType { const { sessionId } = get(); + set((state) => + mutate(state, (draft) => { + draft.loading = true; + }) + ); + listenerCommands.stopSession().then(() => { set(initialState); @@ -101,13 +144,31 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType { + console.error("Failed to stop session:", error); + set((state) => + mutate(state, (draft) => { + draft.loading = false; + }) + ); }); }, pause: () => { const { sessionId } = get(); + set((state) => + mutate(state, (draft) => { + draft.loading = true; + }) + ); + listenerCommands.pauseSession().then(() => { - set({ status: "running_paused" }); + set((state) => + mutate(state, (draft) => { + draft.status = "running_paused"; + draft.loading = false; + }) + ); // We need refresh since session in store is now stale. // setTimeout is needed because of debounce. @@ -117,11 +178,36 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType { + console.error("Failed to pause session:", error); + set((state) => + mutate(state, (draft) => { + draft.loading = false; + }) + ); }); }, resume: () => { + set((state) => + mutate(state, (draft) => { + draft.loading = true; + }) + ); + listenerCommands.resumeSession().then(() => { - set({ status: "running_active" }); + set((state) => + mutate(state, (draft) => { + draft.status = "running_active"; + draft.loading = false; + }) + ); + }).catch((error) => { + console.error("Failed to resume session:", error); + set((state) => + mutate(state, (draft) => { + draft.loading = false; + }) + ); }); }, })); diff --git a/plugins/listener/js/bindings.gen.ts b/plugins/listener/js/bindings.gen.ts index 16a9955d5..da49cfd16 100644 --- a/plugins/listener/js/bindings.gen.ts +++ b/plugins/listener/js/bindings.gen.ts @@ -72,7 +72,7 @@ sessionEvent: "plugin:listener:session-event" /** user-defined types **/ -export type SessionEvent = { type: "inactive" } | { type: "running_active" } | { type: "running_paused" } | { type: "words"; words: Word[] } | { type: "audioAmplitude"; mic: number; speaker: number } +export type SessionEvent = { type: "inactive" } | { type: "running_active" } | { type: "running_paused" } | { type: "words"; words: Word[] } | { type: "audioAmplitude"; mic: number; speaker: number } | { type: "micMuted"; value: boolean } | { type: "speakerMuted"; value: boolean } export type SpeakerIdentity = { type: "unassigned"; value: { index: number } } | { type: "assigned"; value: { id: string; label: string } } export type Word = { text: string; speaker: SpeakerIdentity | null; confidence: number | null; start_ms: number | null; end_ms: number | null } diff --git a/plugins/listener/src/events.rs b/plugins/listener/src/events.rs index 23f3a3085..1ba1dcb10 100644 --- a/plugins/listener/src/events.rs +++ b/plugins/listener/src/events.rs @@ -19,6 +19,10 @@ common_event_derives! { Words { words: Vec}, #[serde(rename = "audioAmplitude")] AudioAmplitude { mic: u16, speaker: u16 }, + #[serde(rename = "micMuted")] + MicMuted { value: bool }, + #[serde(rename = "speakerMuted")] + SpeakerMuted { value: bool }, } } diff --git a/plugins/listener/src/fsm.rs b/plugins/listener/src/fsm.rs index 0ac8afcb9..e1ac4a151 100644 --- a/plugins/listener/src/fsm.rs +++ b/plugins/listener/src/fsm.rs @@ -403,12 +403,14 @@ impl Session { StateEvent::MicMuted(muted) => { if let Some(tx) = &self.mic_muted_tx { let _ = tx.send(*muted); + let _ = SessionEvent::MicMuted { value: *muted }.emit(&self.app); } Handled } StateEvent::SpeakerMuted(muted) => { if let Some(tx) = &self.speaker_muted_tx { let _ = tx.send(*muted); + let _ = SessionEvent::SpeakerMuted { value: *muted }.emit(&self.app); } Handled } @@ -477,6 +479,11 @@ impl Session { let _ = self.app.set_start_disabled(false); } + { + use tauri_plugin_windows::{HyprWindow, WindowsPluginExt}; + let _ = self.app.window_hide(HyprWindow::Control); + } + if let Some(session_id) = &self.session_id { use tauri_plugin_db::DatabasePluginExt; @@ -497,6 +504,11 @@ impl Session { #[action] async fn enter_running_active(&mut self) { + { + use tauri_plugin_windows::{HyprWindow, WindowsPluginExt}; + let _ = self.app.window_show(HyprWindow::Control); + } + if let Some(session_id) = &self.session_id { use tauri_plugin_db::DatabasePluginExt; diff --git a/plugins/windows/Cargo.toml b/plugins/windows/Cargo.toml index be22c1aa3..f084a9370 100644 --- a/plugins/windows/Cargo.toml +++ b/plugins/windows/Cargo.toml @@ -15,6 +15,9 @@ specta-typescript = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { workspace = true } +objc2 = { workspace = true } +objc2-app-kit = { workspace = true } +objc2-foundation = { workspace = true } [dependencies] serde = { workspace = true } @@ -28,5 +31,7 @@ tauri-specta = { workspace = true, features = ["derive", "typescript"] } tauri-plugin-analytics = { workspace = true } tauri-plugin-auth = { workspace = true } +once_cell = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } diff --git a/plugins/windows/build.rs b/plugins/windows/build.rs index 1d4b3cb05..6abb41018 100644 --- a/plugins/windows/build.rs +++ b/plugins/windows/build.rs @@ -1,5 +1,6 @@ const COMMANDS: &[&str] = &[ "window_show", + "window_close", "window_hide", "window_destroy", "window_position", @@ -8,7 +9,10 @@ const COMMANDS: &[&str] = &[ "window_navigate", "window_emit_navigate", "window_is_visible", - "window_resize_default", + "window_set_overlay_bounds", + "window_remove_overlay_bounds", + "set_fake_window_bounds", + "remove_fake_window", ]; fn main() { diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index c90633abf..da93ff0ac 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -10,6 +10,9 @@ export const commands = { async windowShow(window: HyprWindow) : Promise { return await TAURI_INVOKE("plugin:windows|window_show", { window }); }, +async windowClose(window: HyprWindow) : Promise { + return await TAURI_INVOKE("plugin:windows|window_close", { window }); +}, async windowHide(window: HyprWindow) : Promise { return await TAURI_INVOKE("plugin:windows|window_hide", { window }); }, @@ -19,9 +22,6 @@ async windowDestroy(window: HyprWindow) : Promise { async windowPosition(window: HyprWindow, pos: KnownPosition) : Promise { return await TAURI_INVOKE("plugin:windows|window_position", { window, pos }); }, -async windowResizeDefault(window: HyprWindow) : Promise { - return await TAURI_INVOKE("plugin:windows|window_resize_default", { window }); -}, async windowGetFloating(window: HyprWindow) : Promise { return await TAURI_INVOKE("plugin:windows|window_get_floating", { window }); }, @@ -36,6 +36,18 @@ async windowEmitNavigate(window: HyprWindow, path: string) : Promise { }, async windowIsVisible(window: HyprWindow) : Promise { return await TAURI_INVOKE("plugin:windows|window_is_visible", { window }); +}, +async windowSetOverlayBounds(name: string, bounds: OverlayBound) : Promise { + return await TAURI_INVOKE("plugin:windows|window_set_overlay_bounds", { name, bounds }); +}, +async windowRemoveOverlayBounds(name: string) : Promise { + return await TAURI_INVOKE("plugin:windows|window_remove_overlay_bounds", { name }); +}, +async setFakeWindowBounds(name: string, bounds: OverlayBound) : Promise { + return await TAURI_INVOKE("plugin:windows|set_fake_window_bounds", { name, bounds }); +}, +async removeFakeWindow(name: string) : Promise { + return await TAURI_INVOKE("plugin:windows|remove_fake_window", { name }); } } @@ -58,10 +70,11 @@ windowDestroyed: "plugin:windows:window-destroyed" /** user-defined types **/ -export type HyprWindow = { type: "main" } | { type: "note"; value: string } | { type: "human"; value: string } | { type: "organization"; value: string } | { type: "calendar" } | { type: "settings" } | { type: "video"; value: string } | { type: "plans" } +export type HyprWindow = { type: "main" } | { type: "note"; value: string } | { type: "human"; value: string } | { type: "organization"; value: string } | { type: "calendar" } | { type: "settings" } | { type: "video"; value: string } | { type: "plans" } | { type: "control" } export type KnownPosition = "left-half" | "right-half" | "center" export type MainWindowState = { left_sidebar_expanded: boolean | null; right_panel_expanded: boolean | null } export type Navigate = { path: string } +export type OverlayBound = { x: number; y: number; width: number; height: number } export type WindowDestroyed = { window: HyprWindow } /** tauri-specta globals **/ diff --git a/plugins/windows/permissions/autogenerated/commands/remove_fake_window.toml b/plugins/windows/permissions/autogenerated/commands/remove_fake_window.toml new file mode 100644 index 000000000..57e96c689 --- /dev/null +++ b/plugins/windows/permissions/autogenerated/commands/remove_fake_window.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove-fake-window" +description = "Enables the remove_fake_window command without any pre-configured scope." +commands.allow = ["remove_fake_window"] + +[[permission]] +identifier = "deny-remove-fake-window" +description = "Denies the remove_fake_window command without any pre-configured scope." +commands.deny = ["remove_fake_window"] diff --git a/plugins/windows/permissions/autogenerated/commands/set_fake_window_bounds.toml b/plugins/windows/permissions/autogenerated/commands/set_fake_window_bounds.toml new file mode 100644 index 000000000..cc7a685aa --- /dev/null +++ b/plugins/windows/permissions/autogenerated/commands/set_fake_window_bounds.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-fake-window-bounds" +description = "Enables the set_fake_window_bounds command without any pre-configured scope." +commands.allow = ["set_fake_window_bounds"] + +[[permission]] +identifier = "deny-set-fake-window-bounds" +description = "Denies the set_fake_window_bounds command without any pre-configured scope." +commands.deny = ["set_fake_window_bounds"] diff --git a/plugins/windows/permissions/autogenerated/commands/window_close.toml b/plugins/windows/permissions/autogenerated/commands/window_close.toml new file mode 100644 index 000000000..0f822878c --- /dev/null +++ b/plugins/windows/permissions/autogenerated/commands/window_close.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-window-close" +description = "Enables the window_close command without any pre-configured scope." +commands.allow = ["window_close"] + +[[permission]] +identifier = "deny-window-close" +description = "Denies the window_close command without any pre-configured scope." +commands.deny = ["window_close"] diff --git a/plugins/windows/permissions/autogenerated/commands/window_remove_overlay_bounds.toml b/plugins/windows/permissions/autogenerated/commands/window_remove_overlay_bounds.toml new file mode 100644 index 000000000..29eeacda8 --- /dev/null +++ b/plugins/windows/permissions/autogenerated/commands/window_remove_overlay_bounds.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-window-remove-overlay-bounds" +description = "Enables the window_remove_overlay_bounds command without any pre-configured scope." +commands.allow = ["window_remove_overlay_bounds"] + +[[permission]] +identifier = "deny-window-remove-overlay-bounds" +description = "Denies the window_remove_overlay_bounds command without any pre-configured scope." +commands.deny = ["window_remove_overlay_bounds"] diff --git a/plugins/windows/permissions/autogenerated/commands/window_set_overlay_bounds.toml b/plugins/windows/permissions/autogenerated/commands/window_set_overlay_bounds.toml new file mode 100644 index 000000000..75d7dc625 --- /dev/null +++ b/plugins/windows/permissions/autogenerated/commands/window_set_overlay_bounds.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-window-set-overlay-bounds" +description = "Enables the window_set_overlay_bounds command without any pre-configured scope." +commands.allow = ["window_set_overlay_bounds"] + +[[permission]] +identifier = "deny-window-set-overlay-bounds" +description = "Denies the window_set_overlay_bounds command without any pre-configured scope." +commands.deny = ["window_set_overlay_bounds"] diff --git a/plugins/windows/permissions/autogenerated/reference.md b/plugins/windows/permissions/autogenerated/reference.md index 4c73cb197..9d1d03ba0 100644 --- a/plugins/windows/permissions/autogenerated/reference.md +++ b/plugins/windows/permissions/autogenerated/reference.md @@ -5,15 +5,19 @@ Default permissions for the plugin #### This default permission set includes the following: - `allow-window-show` +- `allow-window-close` - `allow-window-hide` - `allow-window-destroy` - `allow-window-position` -- `allow-window-resize-default` - `allow-window-get-floating` - `allow-window-set-floating` - `allow-window-navigate` - `allow-window-emit-navigate` - `allow-window-is-visible` +- `allow-window-set-overlay-bounds` +- `allow-window-remove-overlay-bounds` +- `allow-set-fake-window-bounds` +- `allow-remove-fake-window` ## Permission Table @@ -24,6 +28,84 @@ Default permissions for the plugin + + + +`windows:allow-remove-fake-window` + + + + +Enables the remove_fake_window command without any pre-configured scope. + + + + + + + +`windows:deny-remove-fake-window` + + + + +Denies the remove_fake_window command without any pre-configured scope. + + + + + + + +`windows:allow-set-fake-window-bounds` + + + + +Enables the set_fake_window_bounds command without any pre-configured scope. + + + + + + + +`windows:deny-set-fake-window-bounds` + + + + +Denies the set_fake_window_bounds command without any pre-configured scope. + + + + + + + +`windows:allow-window-close` + + + + +Enables the window_close command without any pre-configured scope. + + + + + + + +`windows:deny-window-close` + + + + +Denies the window_close command without any pre-configured scope. + + + + @@ -209,6 +291,32 @@ Denies the window_position command without any pre-configured scope. +`windows:allow-window-remove-overlay-bounds` + + + + +Enables the window_remove_overlay_bounds command without any pre-configured scope. + + + + + + + +`windows:deny-window-remove-overlay-bounds` + + + + +Denies the window_remove_overlay_bounds command without any pre-configured scope. + + + + + + + `windows:allow-window-resize-default` @@ -261,6 +369,32 @@ Denies the window_set_floating command without any pre-configured scope. +`windows:allow-window-set-overlay-bounds` + + + + +Enables the window_set_overlay_bounds command without any pre-configured scope. + + + + + + + +`windows:deny-window-set-overlay-bounds` + + + + +Denies the window_set_overlay_bounds command without any pre-configured scope. + + + + + + + `windows:allow-window-show` diff --git a/plugins/windows/permissions/default.toml b/plugins/windows/permissions/default.toml index d72b93cd5..8788da817 100644 --- a/plugins/windows/permissions/default.toml +++ b/plugins/windows/permissions/default.toml @@ -2,13 +2,17 @@ description = "Default permissions for the plugin" permissions = [ "allow-window-show", - "allow-window-hide", + "allow-window-close", + "allow-window-hide", "allow-window-destroy", "allow-window-position", - "allow-window-resize-default", "allow-window-get-floating", "allow-window-set-floating", "allow-window-navigate", "allow-window-emit-navigate", "allow-window-is-visible", + "allow-window-set-overlay-bounds", + "allow-window-remove-overlay-bounds", + "allow-set-fake-window-bounds", + "allow-remove-fake-window", ] diff --git a/plugins/windows/permissions/schemas/schema.json b/plugins/windows/permissions/schemas/schema.json index cc865df44..8c999c38e 100644 --- a/plugins/windows/permissions/schemas/schema.json +++ b/plugins/windows/permissions/schemas/schema.json @@ -294,6 +294,42 @@ "PermissionKind": { "type": "string", "oneOf": [ + { + "description": "Enables the remove_fake_window command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-fake-window", + "markdownDescription": "Enables the remove_fake_window command without any pre-configured scope." + }, + { + "description": "Denies the remove_fake_window command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-fake-window", + "markdownDescription": "Denies the remove_fake_window command without any pre-configured scope." + }, + { + "description": "Enables the set_fake_window_bounds command without any pre-configured scope.", + "type": "string", + "const": "allow-set-fake-window-bounds", + "markdownDescription": "Enables the set_fake_window_bounds command without any pre-configured scope." + }, + { + "description": "Denies the set_fake_window_bounds command without any pre-configured scope.", + "type": "string", + "const": "deny-set-fake-window-bounds", + "markdownDescription": "Denies the set_fake_window_bounds command without any pre-configured scope." + }, + { + "description": "Enables the window_close command without any pre-configured scope.", + "type": "string", + "const": "allow-window-close", + "markdownDescription": "Enables the window_close command without any pre-configured scope." + }, + { + "description": "Denies the window_close command without any pre-configured scope.", + "type": "string", + "const": "deny-window-close", + "markdownDescription": "Denies the window_close command without any pre-configured scope." + }, { "description": "Enables the window_destroy command without any pre-configured scope.", "type": "string", @@ -378,6 +414,18 @@ "const": "deny-window-position", "markdownDescription": "Denies the window_position command without any pre-configured scope." }, + { + "description": "Enables the window_remove_overlay_bounds command without any pre-configured scope.", + "type": "string", + "const": "allow-window-remove-overlay-bounds", + "markdownDescription": "Enables the window_remove_overlay_bounds command without any pre-configured scope." + }, + { + "description": "Denies the window_remove_overlay_bounds command without any pre-configured scope.", + "type": "string", + "const": "deny-window-remove-overlay-bounds", + "markdownDescription": "Denies the window_remove_overlay_bounds command without any pre-configured scope." + }, { "description": "Enables the window_resize_default command without any pre-configured scope.", "type": "string", @@ -402,6 +450,18 @@ "const": "deny-window-set-floating", "markdownDescription": "Denies the window_set_floating command without any pre-configured scope." }, + { + "description": "Enables the window_set_overlay_bounds command without any pre-configured scope.", + "type": "string", + "const": "allow-window-set-overlay-bounds", + "markdownDescription": "Enables the window_set_overlay_bounds command without any pre-configured scope." + }, + { + "description": "Denies the window_set_overlay_bounds command without any pre-configured scope.", + "type": "string", + "const": "deny-window-set-overlay-bounds", + "markdownDescription": "Denies the window_set_overlay_bounds command without any pre-configured scope." + }, { "description": "Enables the window_show command without any pre-configured scope.", "type": "string", @@ -415,10 +475,10 @@ "markdownDescription": "Denies the window_show command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-window-show`\n- `allow-window-hide`\n- `allow-window-destroy`\n- `allow-window-position`\n- `allow-window-resize-default`\n- `allow-window-get-floating`\n- `allow-window-set-floating`\n- `allow-window-navigate`\n- `allow-window-emit-navigate`\n- `allow-window-is-visible`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-window-show`\n- `allow-window-close`\n- `allow-window-hide`\n- `allow-window-destroy`\n- `allow-window-position`\n- `allow-window-get-floating`\n- `allow-window-set-floating`\n- `allow-window-navigate`\n- `allow-window-emit-navigate`\n- `allow-window-is-visible`\n- `allow-window-set-overlay-bounds`\n- `allow-window-remove-overlay-bounds`\n- `allow-set-fake-window-bounds`\n- `allow-remove-fake-window`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-window-show`\n- `allow-window-hide`\n- `allow-window-destroy`\n- `allow-window-position`\n- `allow-window-resize-default`\n- `allow-window-get-floating`\n- `allow-window-set-floating`\n- `allow-window-navigate`\n- `allow-window-emit-navigate`\n- `allow-window-is-visible`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-window-show`\n- `allow-window-close`\n- `allow-window-hide`\n- `allow-window-destroy`\n- `allow-window-position`\n- `allow-window-get-floating`\n- `allow-window-set-floating`\n- `allow-window-navigate`\n- `allow-window-emit-navigate`\n- `allow-window-is-visible`\n- `allow-window-set-overlay-bounds`\n- `allow-window-remove-overlay-bounds`\n- `allow-set-fake-window-bounds`\n- `allow-remove-fake-window`" } ] } diff --git a/plugins/windows/src/commands.rs b/plugins/windows/src/commands.rs index ec79b0d85..a4d28a614 100644 --- a/plugins/windows/src/commands.rs +++ b/plugins/windows/src/commands.rs @@ -1,4 +1,4 @@ -use crate::{HyprWindow, KnownPosition, WindowsPluginExt}; +use crate::{FakeWindowBounds, HyprWindow, KnownPosition, OverlayBound, WindowsPluginExt}; #[tauri::command] #[specta::specta] @@ -12,43 +12,42 @@ pub async fn window_show( #[tauri::command] #[specta::specta] -pub async fn window_hide( +pub async fn window_close( app: tauri::AppHandle, window: HyprWindow, ) -> Result<(), String> { - app.window_hide(window).map_err(|e| e.to_string())?; + app.window_close(window).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] #[specta::specta] -pub async fn window_destroy( +pub async fn window_hide( app: tauri::AppHandle, window: HyprWindow, ) -> Result<(), String> { - app.window_destroy(window).map_err(|e| e.to_string())?; + app.window_hide(window).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] #[specta::specta] -pub async fn window_position( +pub async fn window_destroy( app: tauri::AppHandle, window: HyprWindow, - pos: KnownPosition, ) -> Result<(), String> { - app.window_position(window, pos) - .map_err(|e| e.to_string())?; + app.window_destroy(window).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] #[specta::specta] -pub async fn window_resize_default( +pub async fn window_position( app: tauri::AppHandle, window: HyprWindow, + pos: KnownPosition, ) -> Result<(), String> { - app.window_resize_default(window) + app.window_position(window, pos) .map_err(|e| e.to_string())?; Ok(()) } @@ -108,3 +107,76 @@ pub async fn window_emit_navigate( .map_err(|e| e.to_string())?; Ok(()) } + +async fn update_bounds( + window: &tauri::Window, + state: &tauri::State<'_, FakeWindowBounds>, + name: String, + bounds: OverlayBound, +) -> Result<(), String> { + let mut state = state.0.write().await; + let map = state.entry(window.label().to_string()).or_default(); + map.insert(name, bounds); + Ok(()) +} + +async fn remove_bounds( + window: &tauri::Window, + state: &tauri::State<'_, FakeWindowBounds>, + name: String, +) -> Result<(), String> { + let mut state = state.0.write().await; + let Some(map) = state.get_mut(window.label()) else { + return Ok(()); + }; + + map.remove(&name); + + if map.is_empty() { + state.remove(window.label()); + } + + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn window_set_overlay_bounds( + window: tauri::Window, + state: tauri::State<'_, FakeWindowBounds>, + name: String, + bounds: OverlayBound, +) -> Result<(), String> { + update_bounds(&window, &state, name, bounds).await +} + +#[tauri::command] +#[specta::specta] +pub async fn window_remove_overlay_bounds( + window: tauri::Window, + state: tauri::State<'_, FakeWindowBounds>, + name: String, +) -> Result<(), String> { + remove_bounds(&window, &state, name).await +} + +#[tauri::command] +#[specta::specta] +pub async fn set_fake_window_bounds( + window: tauri::Window, + name: String, + bounds: OverlayBound, + state: tauri::State<'_, FakeWindowBounds>, +) -> Result<(), String> { + update_bounds(&window, &state, name, bounds).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remove_fake_window( + window: tauri::Window, + name: String, + state: tauri::State<'_, FakeWindowBounds>, +) -> Result<(), String> { + remove_bounds(&window, &state, name).await +} diff --git a/plugins/windows/src/ext.rs b/plugins/windows/src/ext.rs index 2b9c38239..3197b3e0e 100644 --- a/plugins/windows/src/ext.rs +++ b/plugins/windows/src/ext.rs @@ -26,6 +26,8 @@ pub enum HyprWindow { Video(String), #[serde(rename = "plans")] Plans, + #[serde(rename = "control")] + Control, } impl std::fmt::Display for HyprWindow { @@ -39,6 +41,7 @@ impl std::fmt::Display for HyprWindow { Self::Settings => write!(f, "settings"), Self::Video(id) => write!(f, "video-{}", id), Self::Plans => write!(f, "plans"), + Self::Control => write!(f, "control"), } } } @@ -144,6 +147,7 @@ impl HyprWindow { Self::Settings => "Settings".into(), Self::Video(_) => "Video".into(), Self::Plans => "Plans".into(), + Self::Control => "Control".into(), } } @@ -152,32 +156,6 @@ impl HyprWindow { app.get_webview_window(&label) } - pub fn get_default_size(&self) -> LogicalSize { - match self { - Self::Main => LogicalSize::new(910.0, 600.0), - Self::Note(_) => LogicalSize::new(480.0, 500.0), - Self::Human(_) => LogicalSize::new(480.0, 500.0), - Self::Organization(_) => LogicalSize::new(480.0, 500.0), - Self::Calendar => LogicalSize::new(640.0, 532.0), - Self::Settings => LogicalSize::new(800.0, 600.0), - Self::Video(_) => LogicalSize::new(640.0, 360.0), - Self::Plans => LogicalSize::new(900.0, 600.0), - } - } - - pub fn get_min_size(&self) -> LogicalSize { - match self { - Self::Main => LogicalSize::new(620.0, 500.0), - Self::Note(_) => LogicalSize::new(480.0, 360.0), - Self::Human(_) => LogicalSize::new(480.0, 360.0), - Self::Organization(_) => LogicalSize::new(480.0, 360.0), - Self::Calendar => LogicalSize::new(640.0, 532.0), - Self::Settings => LogicalSize::new(800.0, 600.0), - Self::Video(_) => LogicalSize::new(640.0, 360.0), - Self::Plans => LogicalSize::new(900.0, 600.0), - } - } - pub fn position( &self, app: &AppHandle, @@ -217,6 +195,45 @@ impl HyprWindow { Ok(()) } + fn close(&self, app: &AppHandle) -> Result<(), crate::Error> { + match self { + HyprWindow::Control => { + crate::abort_overlay_join_handle(); + + #[cfg(target_os = "macos")] + { + use tauri_nspanel::ManagerExt; + if let Ok(panel) = app.get_webview_panel(&HyprWindow::Control.label()) { + app.run_on_main_thread({ + let panel = panel.clone(); + move || { + panel.set_released_when_closed(true); + panel.close(); + } + }) + .map_err(|e| { + tracing::warn!("Failed to run panel close on main thread: {}", e) + }) + .ok(); + } + } + #[cfg(not(target_os = "macos"))] + { + if let Some(window) = self.get(app) { + let _ = window.close(); + } + } + } + _ => { + if let Some(window) = self.get(app) { + let _ = window.close(); + } + } + } + + Ok(()) + } + fn hide(&self, app: &AppHandle) -> Result<(), crate::Error> { if let Some(window) = self.get(app) { window.hide()?; @@ -240,120 +257,184 @@ impl HyprWindow { } pub fn show(&self, app: &AppHandle) -> Result { - let (window, created) = match self.get(app) { - Some(window) => (window, false), - None => { - let url = match self { - Self::Main => "/app/new", - Self::Note(id) => &format!("/app/note/{}", id), - Self::Human(id) => &format!("/app/human/{}", id), - Self::Organization(id) => &format!("/app/organization/{}", id), - Self::Calendar => "/app/calendar", - Self::Settings => "/app/settings", - Self::Video(id) => &format!("/video?id={}", id), - Self::Plans => "/app/plans", - }; - (self.window_builder(app, url).build()?, true) - } - }; - - if created { - let default_size = self.get_default_size(); - let min_size = self.get_min_size(); - - match self { - Self::Main => { - window.set_maximizable(true)?; - window.set_minimizable(true)?; - - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; - } - Self::Note(_) => { - window.hide()?; - std::thread::sleep(std::time::Duration::from_millis(100)); - - window.set_maximizable(false)?; - window.set_minimizable(false)?; - - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; - - window.center()?; - } - Self::Human(_) => { - window.hide()?; - std::thread::sleep(std::time::Duration::from_millis(100)); - - window.set_maximizable(false)?; - window.set_minimizable(false)?; - - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; - - window.center()?; - } - Self::Organization(_) => { - window.hide()?; - std::thread::sleep(std::time::Duration::from_millis(100)); + if self == &Self::Main { + use tauri_plugin_analytics::{hypr_analytics::AnalyticsPayload, AnalyticsPluginExt}; + use tauri_plugin_auth::{AuthPluginExt, StoreKey}; - window.set_maximizable(false)?; - window.set_minimizable(false)?; + let user_id = app + .get_from_store(StoreKey::UserId)? + .unwrap_or("UNKNOWN".into()); - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; + let e = AnalyticsPayload::for_user(user_id) + .event("show_main_window") + .build(); - window.center()?; + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = app_clone.event(e).await { + tracing::error!("failed_to_send_analytics: {:?}", e); } - Self::Calendar => { - window.hide()?; - std::thread::sleep(std::time::Duration::from_millis(100)); - - window.set_maximizable(false)?; - window.set_minimizable(false)?; + }); + } - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; + if let Some(window) = self.get(app) { + window.set_focus()?; + window.show()?; + return Ok(window); + } - window.center()?; + let monitor = app + .primary_monitor()? + .ok_or_else(|| crate::Error::MonitorNotFound)?; + + let window = match self { + Self::Main => { + let builder = self + .window_builder(app, "/app/new") + .maximizable(true) + .minimizable(true) + .min_inner_size(620.0, 500.0); + let window = builder.build()?; + window.set_size(LogicalSize::new(910.0, 600.0))?; + window + } + Self::Note(id) => self + .window_builder(app, &format!("/app/note/{}", id)) + .inner_size(480.0, 500.0) + .min_inner_size(480.0, 360.0) + .center() + .build()?, + Self::Human(id) => self + .window_builder(app, &format!("/app/human/{}", id)) + .inner_size(480.0, 500.0) + .min_inner_size(480.0, 360.0) + .center() + .build()?, + Self::Organization(id) => self + .window_builder(app, &format!("/app/organization/{}", id)) + .inner_size(480.0, 500.0) + .min_inner_size(480.0, 360.0) + .center() + .build()?, + Self::Calendar => self + .window_builder(app, "/app/calendar") + .inner_size(640.0, 532.0) + .min_inner_size(640.0, 532.0) + .build()?, + Self::Settings => self + .window_builder(app, "/app/settings") + .inner_size(800.0, 600.0) + .min_inner_size(800.0, 600.0) + .build()?, + Self::Video(id) => self + .window_builder(app, &format!("/video?id={}", id)) + .maximizable(false) + .minimizable(false) + .inner_size(640.0, 360.0) + .min_inner_size(640.0, 360.0) + .build()?, + Self::Plans => self + .window_builder(app, "/app/plans") + .maximizable(false) + .minimizable(false) + .inner_size(900.0, 600.0) + .min_inner_size(900.0, 600.0) + .build()?, + Self::Control => { + let window_width = (monitor.size().width as f64) / monitor.scale_factor(); + let window_height = (monitor.size().height as f64) / monitor.scale_factor(); + + let mut builder = WebviewWindow::builder( + app, + self.label(), + WebviewUrl::App("/app/control".into()), + ) + .title("") + .disable_drag_drop_handler() + .maximized(false) + .resizable(false) + .fullscreen(false) + .shadow(false) + .always_on_top(true) + .visible_on_all_workspaces(true) + .accept_first_mouse(true) + .content_protected(true) + .inner_size(window_width, window_height) + .skip_taskbar(true) + .position(0.0, 0.0) + .transparent(true); + + #[cfg(target_os = "macos")] + { + builder = builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); } - Self::Settings => { - window.hide()?; - std::thread::sleep(std::time::Duration::from_millis(100)); - - window.set_maximizable(false)?; - window.set_minimizable(false)?; - - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; - window.center()?; + #[cfg(not(target_os = "macos"))] + { + builder = builder.decorations(false); } - Self::Video(_) => { - window.hide()?; - std::thread::sleep(std::time::Duration::from_millis(100)); - window.set_resizable(false)?; - window.set_maximizable(false)?; - window.set_minimizable(false)?; - - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; + let window = builder.build()?; + + #[cfg(target_os = "macos")] + { + #[allow(deprecated, unexpected_cfgs)] + app.run_on_main_thread({ + let window = window.clone(); + move || { + use objc2::runtime::AnyObject; + use objc2::msg_send; + + // Hide traffic lights using cocoa APIs + if let Ok(ns_window) = window.ns_window() { + unsafe { + let ns_window = ns_window as *mut AnyObject; + let ns_window = &*ns_window; + + // NSWindow button type constants + const NS_WINDOW_CLOSE_BUTTON: u64 = 0; + const NS_WINDOW_MINIATURIZE_BUTTON: u64 = 1; + const NS_WINDOW_ZOOM_BUTTON: u64 = 2; + + // Get and hide the standard window buttons + let close_button: *mut AnyObject = msg_send![ns_window, standardWindowButton: NS_WINDOW_CLOSE_BUTTON]; + let miniaturize_button: *mut AnyObject = msg_send![ns_window, standardWindowButton: NS_WINDOW_MINIATURIZE_BUTTON]; + let zoom_button: *mut AnyObject = msg_send![ns_window, standardWindowButton: NS_WINDOW_ZOOM_BUTTON]; + + if !close_button.is_null() { + let _: () = msg_send![close_button, setHidden: true]; + } + if !miniaturize_button.is_null() { + let _: () = msg_send![miniaturize_button, setHidden: true]; + } + if !zoom_button.is_null() { + let _: () = msg_send![zoom_button, setHidden: true]; + } + + // Make title bar transparent instead of changing style mask + let _: () = msg_send![ns_window, setTitlebarAppearsTransparent: true]; + let _: () = msg_send![ns_window, setMovableByWindowBackground: true]; + } + } + } + }).map_err(|e| tracing::warn!("Failed to run window setup on main thread: {}", e)).ok(); } - Self::Plans => { - window.hide()?; - std::thread::sleep(std::time::Duration::from_millis(100)); - window.set_maximizable(false)?; - window.set_minimizable(false)?; + let join_handle = crate::spawn_overlay_listener(app.clone(), window.clone()); + crate::set_overlay_join_handle(join_handle); - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; + // Cancel the overlay listener when the window is closed + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + crate::abort_overlay_join_handle(); + } + }); - window.center()?; - } - }; - } + window + } + }; window.set_focus()?; window.show()?; @@ -374,18 +455,23 @@ impl HyprWindow { ) -> WebviewWindowBuilder<'a, tauri::Wry, AppHandle> { let mut builder = WebviewWindow::builder(app, self.label(), WebviewUrl::App(url.into())) .title(self.title()) - .decorations(true) .disable_drag_drop_handler(); #[cfg(target_os = "macos")] { builder = builder + .decorations(true) .hidden_title(true) .theme(Some(tauri::Theme::Light)) .traffic_light_position(tauri::LogicalPosition::new(12.0, 20.0)) .title_bar_style(tauri::TitleBarStyle::Overlay); } + #[cfg(target_os = "windows")] + { + builder = builder.decorations(false); + } + builder } } @@ -394,10 +480,10 @@ pub trait WindowsPluginExt { fn handle_main_window_visibility(&self, visible: bool) -> Result<(), crate::Error>; fn window_show(&self, window: HyprWindow) -> Result; + fn window_close(&self, window: HyprWindow) -> Result<(), crate::Error>; fn window_hide(&self, window: HyprWindow) -> Result<(), crate::Error>; fn window_destroy(&self, window: HyprWindow) -> Result<(), crate::Error>; fn window_position(&self, window: HyprWindow, pos: KnownPosition) -> Result<(), crate::Error>; - fn window_resize_default(&self, window: HyprWindow) -> Result<(), crate::Error>; fn window_is_visible(&self, window: HyprWindow) -> Result; fn window_get_floating(&self, window: HyprWindow) -> Result; @@ -474,6 +560,10 @@ impl WindowsPluginExt for AppHandle { window.show(self) } + fn window_close(&self, window: HyprWindow) -> Result<(), crate::Error> { + window.close(self) + } + fn window_hide(&self, window: HyprWindow) -> Result<(), crate::Error> { window.hide(self) } @@ -486,15 +576,6 @@ impl WindowsPluginExt for AppHandle { window.position(self, pos) } - fn window_resize_default(&self, window: HyprWindow) -> Result<(), crate::Error> { - if let Some(w) = window.get(self) { - let default_size = window.get_default_size(); - w.set_size(default_size)?; - } - - Ok(()) - } - fn window_is_visible(&self, window: HyprWindow) -> Result { window.is_visible(self) } diff --git a/plugins/windows/src/lib.rs b/plugins/windows/src/lib.rs index 8f1f65a2a..d10bf325a 100644 --- a/plugins/windows/src/lib.rs +++ b/plugins/windows/src/lib.rs @@ -2,18 +2,43 @@ mod commands; mod errors; mod events; mod ext; +mod overlay; pub use errors::*; pub use events::*; pub use ext::*; +use overlay::*; +pub use overlay::{FakeWindowBounds, OverlayBound}; const PLUGIN_NAME: &str = "windows"; +use once_cell::sync::Lazy; +use std::sync::Mutex; use tauri::Manager; use uuid::Uuid; pub type ManagedState = std::sync::Mutex; +static OVERLAY_JOIN_HANDLE: Lazy>>> = + Lazy::new(|| Mutex::new(None)); + +pub fn set_overlay_join_handle(handle: tokio::task::JoinHandle<()>) { + if let Ok(mut guard) = OVERLAY_JOIN_HANDLE.lock() { + if let Some(old_handle) = guard.take() { + old_handle.abort(); + } + *guard = Some(handle); + } +} + +pub fn abort_overlay_join_handle() { + if let Ok(mut guard) = OVERLAY_JOIN_HANDLE.lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } +} + pub struct WindowState { id: String, floating: bool, @@ -45,15 +70,19 @@ fn make_specta_builder() -> tauri_specta::Builder { ]) .commands(tauri_specta::collect_commands![ commands::window_show, + commands::window_close, commands::window_hide, commands::window_destroy, commands::window_position, - commands::window_resize_default, commands::window_get_floating, commands::window_set_floating, commands::window_navigate, commands::window_emit_navigate, commands::window_is_visible, + commands::window_set_overlay_bounds, + commands::window_remove_overlay_bounds, + commands::set_fake_window_bounds, + commands::remove_fake_window, ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) } @@ -65,8 +94,17 @@ pub fn init() -> tauri::plugin::TauriPlugin { .invoke_handler(specta_builder.invoke_handler()) .setup(move |app, _api| { specta_builder.mount_events(app); - let state = ManagedState::default(); - app.manage(state); + + { + let state = ManagedState::default(); + app.manage(state); + } + + { + let fake_bounds_state = FakeWindowBounds::default(); + app.manage(fake_bounds_state); + } + Ok(()) }) .build() diff --git a/plugins/windows/src/overlay.rs b/plugins/windows/src/overlay.rs new file mode 100644 index 000000000..d60a96365 --- /dev/null +++ b/plugins/windows/src/overlay.rs @@ -0,0 +1,106 @@ +use std::{collections::HashMap, sync::Arc, time::Duration}; +use tauri::{AppHandle, Manager, WebviewWindow}; +use tokio::{sync::RwLock, time::sleep}; + +#[derive(Debug, Default, serde::Serialize, serde::Deserialize, specta::Type, Clone, Copy)] +pub struct OverlayBound { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +pub struct FakeWindowBounds(pub Arc>>>); + +impl Default for FakeWindowBounds { + fn default() -> Self { + Self(Arc::new(RwLock::new(HashMap::new()))) + } +} + +pub fn spawn_overlay_listener( + app: AppHandle, + window: WebviewWindow, +) -> tokio::task::JoinHandle<()> { + window.set_ignore_cursor_events(true).ok(); + + tokio::spawn(async move { + let state = app.state::(); + let mut last_ignore_state = true; + let mut last_focus_state = false; + + loop { + sleep(Duration::from_millis(1000 / 10)).await; + + let map = state.0.read().await; + + let Some(windows) = map.get(window.label()) else { + if !last_ignore_state { + window.set_ignore_cursor_events(true).ok(); + last_ignore_state = true; + } + continue; + }; + + if windows.is_empty() { + if !last_ignore_state { + window.set_ignore_cursor_events(true).ok(); + last_ignore_state = true; + } + continue; + }; + + let (Ok(window_position), Ok(mouse_position), Ok(scale_factor)) = ( + window.outer_position(), + window.cursor_position(), + window.scale_factor(), + ) else { + if !last_ignore_state { + if let Err(e) = window.set_ignore_cursor_events(true) { + tracing::warn!("Failed to set ignore cursor events: {}", e); + } + last_ignore_state = true; + } + continue; + }; + + let mut ignore = true; + + for (_name, bounds) in windows.iter() { + let x_min = (window_position.x as f64) + bounds.x * scale_factor; + let x_max = (window_position.x as f64) + (bounds.x + bounds.width) * scale_factor; + let y_min = (window_position.y as f64) + bounds.y * scale_factor; + let y_max = (window_position.y as f64) + (bounds.y + bounds.height) * scale_factor; + + if mouse_position.x >= x_min + && mouse_position.x <= x_max + && mouse_position.y >= y_min + && mouse_position.y <= y_max + { + ignore = false; + break; + } + } + + if ignore != last_ignore_state { + if let Err(e) = window.set_ignore_cursor_events(ignore) { + tracing::warn!("Failed to set ignore cursor events: {}", e); + } + last_ignore_state = ignore; + } + + let focused = window.is_focused().unwrap_or(false); + if !ignore && !focused { + // Only try to set focus if we haven't already done so for this hover state + if !last_focus_state { + if window.set_focus().is_ok() { + last_focus_state = true; + } + } + } else if ignore || focused { + // Reset focus state when cursor leaves or window gains focus naturally + last_focus_state = false; + } + } + }) +}