diff --git a/apps/desktop/src/components/main/sidebar/profile/ota.tsx b/apps/desktop/src/components/main/sidebar/profile/ota.tsx deleted file mode 100644 index 537c33658d..0000000000 --- a/apps/desktop/src/components/main/sidebar/profile/ota.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { Spinner } from "@hypr/ui/components/ui/spinner"; - -import { relaunch } from "@tauri-apps/plugin-process"; -import { check, type Update } from "@tauri-apps/plugin-updater"; -import { createStore } from "@xstate/store"; -import { useSelector } from "@xstate/store/react"; -import { clsx } from "clsx"; -import { AlertCircle, CheckCircle, Download, RefreshCw, X } from "lucide-react"; - -import { MenuItem } from "./shared"; - -type State = - | "idle" - | "checking" - | "error" - | "noUpdate" - | "available" - | "downloading" - | "ready" - | "installing"; - -interface Context { - update: Update | null; - error: string | null; - downloadProgress: { - downloaded: number; - total: number | null; - percentage: number; - }; - state: State; -} - -const updateStore = createStore({ - context: { - update: null, - error: null, - downloadProgress: { - downloaded: 0, - total: null, - percentage: 0, - }, - state: "idle" as State, - } as Context, - on: { - setState: (context, event: { state: State }) => ({ - ...context, - state: event.state, - }), - checkSuccess: (context, event: { update: Update | null }) => ({ - ...context, - update: event.update, - error: null, - state: event.update ? ("available" as State) : ("noUpdate" as State), - }), - checkError: (context, event: { error: string }) => ({ - ...context, - error: event.error, - update: null, - state: "error" as State, - }), - startDownload: (context) => ({ - ...context, - downloadProgress: { - downloaded: 0, - total: null, - percentage: 0, - }, - state: "downloading" as State, - }), - downloadProgress: (context, event: { chunkLength: number; contentLength?: number }) => ({ - ...context, - downloadProgress: { - downloaded: context.downloadProgress.downloaded + event.chunkLength, - total: event.contentLength ?? context.downloadProgress.total, - percentage: event.contentLength || context.downloadProgress.total - ? Math.round( - ((context.downloadProgress.downloaded + event.chunkLength) - / (event.contentLength ?? context.downloadProgress.total ?? 1)) - * 100, - ) - : 0, - }, - }), - downloadFinished: (context) => ({ - ...context, - state: "ready" as State, - }), - cancelDownload: (context) => ({ - ...context, - update: null, - downloadProgress: { - downloaded: 0, - total: null, - percentage: 0, - }, - state: "idle" as State, - }), - setInstalling: (context) => ({ - ...context, - state: "installing" as State, - }), - reset: (context) => ({ - ...context, - update: null, - error: null, - downloadProgress: { - downloaded: 0, - total: null, - percentage: 0, - }, - state: "idle" as State, - }), - }, -}); - -export const checkForUpdate = async () => { - updateStore.trigger.setState({ state: "checking" }); - - try { - const update = await check(); - updateStore.trigger.checkSuccess({ update }); - - if (!update) { - setTimeout(() => { - const currentState = updateStore.getSnapshot().context.state; - if (currentState === "noUpdate") { - updateStore.trigger.reset(); - } - }, 2000); - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to check for updates"; - updateStore.trigger.checkError({ error: errorMessage }); - } -}; - -export function UpdateChecker() { - const snapshot = useSelector(updateStore, (state) => state.context); - const { state, update, error, downloadProgress } = snapshot; - - const handleCheckForUpdate = () => checkForUpdate(); - - const handleStartDownload = async () => { - if (!update) { - return; - } - - updateStore.trigger.startDownload(); - - try { - await update.download((event) => { - if (event.event === "Started") { - updateStore.trigger.downloadProgress({ - chunkLength: 0, - contentLength: event.data.contentLength, - }); - } else if (event.event === "Progress") { - updateStore.trigger.downloadProgress({ - chunkLength: event.data.chunkLength, - }); - } else if (event.event === "Finished") { - updateStore.trigger.downloadFinished(); - } - }); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Download failed"; - updateStore.trigger.checkError({ error: errorMessage }); - } - }; - - const handleCancelDownload = async () => { - if (update) { - try { - await update.close(); - } catch (err) { - console.error("Failed to close update:", err); - } - } - updateStore.trigger.cancelDownload(); - }; - - const handleInstall = async () => { - if (!update) { - return; - } - - updateStore.trigger.setInstalling(); - - try { - if (process.env.NODE_ENV !== "development") { - await update.install(); - await relaunch(); - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Installation failed"; - updateStore.trigger.checkError({ error: errorMessage }); - } - }; - - const handleRetry = () => { - handleCheckForUpdate(); - }; - - if (state === "checking") { - return ( -
- - Checking for updates... -
- ); - } - - if (state === "noUpdate") { - return ( -
- - You're up to date -
- ); - } - - if (state === "error") { - return ( - { - e.stopPropagation(); - handleRetry(); - }} - className={clsx( - "rounded-full", - "px-2 py-0.5", - "bg-red-50", - "text-xs font-semibold text-red-600", - "hover:bg-red-100", - )} - > - Retry - - } - onClick={() => {}} - /> - ); - } - - if (state === "available") { - return ( - } - onClick={handleStartDownload} - /> - ); - } - - if (state === "downloading") { - return ( -
-
- - - Downloading... {downloadProgress.percentage}% - - -
-
-
-
-
- ); - } - - if (state === "ready") { - return ( - } - onClick={handleInstall} - /> - ); - } - - if (state === "installing") { - return ( -
- - Installing... -
- ); - } - - return ( - - ); -} diff --git a/apps/desktop/src/components/main/sidebar/profile/ota/index.tsx b/apps/desktop/src/components/main/sidebar/profile/ota/index.tsx new file mode 100644 index 0000000000..2ee622bf5a --- /dev/null +++ b/apps/desktop/src/components/main/sidebar/profile/ota/index.tsx @@ -0,0 +1,155 @@ +import { clsx } from "clsx"; +import { AlertCircle, CheckCircle, Download, RefreshCw, X } from "lucide-react"; + +import { Spinner } from "@hypr/ui/components/ui/spinner"; +import { cn } from "@hypr/utils"; +import { MenuItem } from "../shared"; +import { useOTA } from "./task"; + +export function UpdateChecker() { + const { + state, + update, + error, + downloadProgress, + handleCheckForUpdate, + handleStartDownload, + handleCancelDownload, + handleInstall, + } = useOTA(); + + if (state === "checking") { + return ( + {}} + /> + ); + } + + if (state === "noUpdate") { + return ( + {}} + /> + ); + } + + if (state === "error") { + return ( + { + e.stopPropagation(); + handleCheckForUpdate(); + }} + className={clsx( + "rounded-full", + "px-2 py-0.5", + "bg-red-50", + "text-xs font-semibold text-red-600", + "hover:bg-red-100", + )} + > + Retry + + } + onClick={() => {}} + /> + ); + } + + if (state === "available") { + return ( + } + onClick={handleStartDownload} + /> + ); + } + + if (state === "downloading") { + return ( + +
+ + + Downloading... + + +
+
+
+
+ + ); + } + + if (state === "ready") { + return ( + } + onClick={handleInstall} + /> + ); + } + + if (state === "installing") { + return ( + {}} + /> + ); + } + + if (state === "idle") { + return ( + + ); + } +} + +function MenuItemLikeContainer({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/desktop/src/components/main/sidebar/profile/ota/store.ts b/apps/desktop/src/components/main/sidebar/profile/ota/store.ts new file mode 100644 index 0000000000..7ea06f6b4c --- /dev/null +++ b/apps/desktop/src/components/main/sidebar/profile/ota/store.ts @@ -0,0 +1,109 @@ +import { type Update } from "@tauri-apps/plugin-updater"; +import { createStore } from "@xstate/store"; + +type State = + | "idle" + | "checking" + | "error" + | "noUpdate" + | "available" + | "downloading" + | "ready" + | "installing"; + +type Context = { + state: State; + update: Update | null; + error: string | null; + downloadProgress: { + downloaded: number; + total: number | null; + percentage: number; + }; +}; + +export const updateStore = createStore({ + context: { + state: "idle" as State, + update: null, + error: null, + downloadProgress: { + downloaded: 0, + total: null, + percentage: 0, + }, + } as Context, + on: { + setState: (context, event: { state: State }) => ({ + ...context, + state: event.state, + }), + checkSuccess: (context, event: { update: Update | null }) => ({ + ...context, + update: event.update, + error: null, + state: event.update ? ("available" as State) : ("noUpdate" as State), + }), + checkError: (context, event: { error: string }) => ({ + ...context, + error: event.error, + update: null, + state: "error" as State, + }), + startDownload: (context) => ({ + ...context, + downloadProgress: { + downloaded: 0, + total: null, + percentage: 0, + }, + state: "downloading" as State, + }), + downloadProgress: (context, event: { chunkLength: number; contentLength?: number }) => ({ + ...context, + downloadProgress: { + downloaded: context.downloadProgress.downloaded + event.chunkLength, + total: event.contentLength ?? context.downloadProgress.total, + percentage: event.contentLength || context.downloadProgress.total + ? Math.min( + 100, + Math.round( + ((context.downloadProgress.downloaded + event.chunkLength) + / (event.contentLength ?? context.downloadProgress.total ?? 1)) + * 100, + ), + ) + : 0, + }, + }), + downloadFinished: (context) => ({ + ...context, + state: "ready" as State, + }), + cancelDownload: (context) => ({ + ...context, + update: null, + downloadProgress: { + downloaded: 0, + total: null, + percentage: 0, + }, + state: "idle" as State, + }), + setInstalling: (context) => ({ + ...context, + state: "installing" as State, + }), + reset: (context) => ({ + ...context, + update: null, + error: null, + downloadProgress: { + downloaded: 0, + total: null, + percentage: 0, + }, + state: "idle" as State, + }), + }, +}); diff --git a/apps/desktop/src/components/main/sidebar/profile/ota/task.ts b/apps/desktop/src/components/main/sidebar/profile/ota/task.ts new file mode 100644 index 0000000000..2b98639aef --- /dev/null +++ b/apps/desktop/src/components/main/sidebar/profile/ota/task.ts @@ -0,0 +1,101 @@ +import { relaunch } from "@tauri-apps/plugin-process"; +import { check } from "@tauri-apps/plugin-updater"; +import { useSelector } from "@xstate/store/react"; + +import { updateStore } from "./store"; + +export const checkForUpdate = async () => { + updateStore.trigger.setState({ state: "checking" }); + + try { + const update = await check(); + updateStore.trigger.checkSuccess({ update }); + + if (!update) { + setTimeout(() => { + const currentState = updateStore.getSnapshot().context.state; + if (currentState === "noUpdate") { + updateStore.trigger.reset(); + } + }, 2000); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to check for updates"; + updateStore.trigger.checkError({ error: errorMessage }); + } +}; + +export function useOTA() { + const snapshot = useSelector(updateStore, (state) => state.context); + + return { + ...snapshot, + handleCheckForUpdate: () => checkForUpdate(), + handleStartDownload, + handleCancelDownload, + handleInstall, + }; +} + +const handleStartDownload = async () => { + const { update } = updateStore.getSnapshot().context; + + if (!update) { + return; + } + + updateStore.trigger.startDownload(); + + try { + await update.download((event) => { + if (event.event === "Started") { + updateStore.trigger.downloadProgress({ + chunkLength: 0, + contentLength: event.data.contentLength, + }); + } else if (event.event === "Progress") { + updateStore.trigger.downloadProgress({ + chunkLength: event.data.chunkLength, + }); + } else if (event.event === "Finished") { + updateStore.trigger.downloadFinished(); + } + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Download failed"; + updateStore.trigger.checkError({ error: errorMessage }); + } +}; + +const handleCancelDownload = async () => { + const { update } = updateStore.getSnapshot().context; + + if (update) { + try { + await update.close(); + } catch (err) { + console.error("Failed to close update:", err); + } + } + updateStore.trigger.cancelDownload(); +}; + +const handleInstall = async () => { + const { update } = updateStore.getSnapshot().context; + + if (!update) { + return; + } + + updateStore.trigger.setInstalling(); + + try { + if (process.env.NODE_ENV !== "development") { + await update.install(); + await relaunch(); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Installation failed"; + updateStore.trigger.checkError({ error: errorMessage }); + } +}; diff --git a/apps/desktop/src/components/main/sidebar/profile/shared.tsx b/apps/desktop/src/components/main/sidebar/profile/shared.tsx index 3e279886d1..2952ed9f64 100644 --- a/apps/desktop/src/components/main/sidebar/profile/shared.tsx +++ b/apps/desktop/src/components/main/sidebar/profile/shared.tsx @@ -1,17 +1,23 @@ -import { clsx } from "clsx"; +import { cn } from "@hypr/utils"; export function MenuItem( - { icon: Icon, label, badge, suffixIcon: SuffixIcon, onClick }: { - icon: any; + { + icon: Icon, + label, + badge, + suffixIcon: SuffixIcon, + onClick, + }: { + icon: React.ComponentType<{ className?: string }>; label: string; badge?: number | React.ReactNode; - suffixIcon?: any; + suffixIcon?: React.ComponentType<{ className?: string }>; onClick: () => void; }, ) { return ( ); } diff --git a/apps/desktop/src/components/task-manager.tsx b/apps/desktop/src/components/task-manager.tsx index a4605c1853..d542d61120 100644 --- a/apps/desktop/src/components/task-manager.tsx +++ b/apps/desktop/src/components/task-manager.tsx @@ -3,7 +3,7 @@ import { useScheduleTaskRun, useSetTask } from "tinytick/ui-react"; import { commands as localSttCommands } from "@hypr/plugin-local-stt"; import type { SupportedSttModel } from "@hypr/plugin-local-stt"; -import { checkForUpdate } from "./main/sidebar/profile/ota"; +import { checkForUpdate } from "./main/sidebar/profile/ota/task"; const UPDATE_CHECK_TASK_ID = "checkForUpdate"; const UPDATE_CHECK_INTERVAL = 30 * 1000; diff --git a/apps/desktop/src/mocks/updater.ts b/apps/desktop/src/mocks/updater.ts index 400001ab99..c9de989f55 100644 --- a/apps/desktop/src/mocks/updater.ts +++ b/apps/desktop/src/mocks/updater.ts @@ -16,7 +16,7 @@ interface Update { close: () => Promise; } -export const check = check_1; +export const check = check_2; export async function check_1(): Promise { await new Promise((resolve) => setTimeout(resolve, 1000)); diff --git a/apps/desktop/src/utils/transcript.test.ts b/apps/desktop/src/utils/transcript.test.ts index 7a905b5cc9..337243a5ab 100644 --- a/apps/desktop/src/utils/transcript.test.ts +++ b/apps/desktop/src/utils/transcript.test.ts @@ -25,14 +25,14 @@ describe("buildSegments", () => { end_ms, }); - it.each([ + const testcases = [ { description: "returns empty array for empty input", words: [], segments: [], }, { - description: "creates single segment from single word", + description: "simple single segment from single word", words: [ WORD({ text: "hello", channel: 0, start_ms: 0, end_ms: 100 }), ], @@ -40,11 +40,36 @@ describe("buildSegments", () => { { speaker: "Speaker 0", text: "hello" }, ], }, - ])("$description", ({ words, segments }) => { - const result = buildSegments({ - words, - speakerFromChannel: (channel) => `Speaker ${channel}`, - }); + { + description: "merge adjacent words from same channel", + words: [ + WORD({ text: "hello", channel: 0, start_ms: 0, end_ms: 100 }), + WORD({ text: "world", channel: 0, start_ms: 120, end_ms: 220 }), + ], + segments: [ + { speaker: "Speaker 0", text: "hello world" }, + ], + }, + { + description: "different channels are different speakers", + words: [ + WORD({ text: "hello", channel: 0, start_ms: 0, end_ms: 100 }), + WORD({ text: "world", channel: 1, start_ms: 120, end_ms: 220 }), + ], + segments: [ + { speaker: "Speaker 0", text: "hello" }, + { speaker: "Speaker 1", text: "world" }, + ], + }, + ]; + + it.each(testcases)("$description", ({ words, segments }) => { + const result = buildSegments( + { + words, + speakerFromChannel: (channel) => `Speaker ${channel}`, + }, + ); expect(result).toEqual(segments); }); diff --git a/apps/desktop/src/utils/transcript.ts b/apps/desktop/src/utils/transcript.ts index 4159d0de86..d6d38852e2 100644 --- a/apps/desktop/src/utils/transcript.ts +++ b/apps/desktop/src/utils/transcript.ts @@ -18,7 +18,13 @@ export const buildSegments = ( for (const word of words) { const speaker = speakerFromChannel(word.channel); - segments.push({ text: word.text, speaker }); + const lastSegment = segments[segments.length - 1]; + + if (lastSegment && lastSegment.speaker === speaker) { + lastSegment.text += ` ${word.text}`; + } else { + segments.push({ text: word.text, speaker }); + } } return segments;