diff --git a/apps/desktop/src-tauri/icons/dynamic/dark.png b/apps/desktop/src-tauri/icons/dynamic/dark.png new file mode 100644 index 0000000000..e204fdf019 Binary files /dev/null and b/apps/desktop/src-tauri/icons/dynamic/dark.png differ diff --git a/apps/desktop/src-tauri/icons/dynamic/light.png b/apps/desktop/src-tauri/icons/dynamic/light.png new file mode 100644 index 0000000000..ab468aa228 Binary files /dev/null and b/apps/desktop/src-tauri/icons/dynamic/light.png differ diff --git a/apps/desktop/src-tauri/icons/dynamic/nightly.png b/apps/desktop/src-tauri/icons/dynamic/nightly.png new file mode 100644 index 0000000000..140e00bf8a Binary files /dev/null and b/apps/desktop/src-tauri/icons/dynamic/nightly.png differ diff --git a/apps/desktop/src-tauri/icons/dynamic/pro.png b/apps/desktop/src-tauri/icons/dynamic/pro.png new file mode 100644 index 0000000000..660a801a5e Binary files /dev/null and b/apps/desktop/src-tauri/icons/dynamic/pro.png differ diff --git a/apps/desktop/src/assets/icons/dark.png b/apps/desktop/src/assets/icons/dark.png new file mode 100644 index 0000000000..e204fdf019 Binary files /dev/null and b/apps/desktop/src/assets/icons/dark.png differ diff --git a/apps/desktop/src/assets/icons/light.png b/apps/desktop/src/assets/icons/light.png new file mode 100644 index 0000000000..ab468aa228 Binary files /dev/null and b/apps/desktop/src/assets/icons/light.png differ diff --git a/apps/desktop/src/assets/icons/nightly.png b/apps/desktop/src/assets/icons/nightly.png new file mode 100644 index 0000000000..140e00bf8a Binary files /dev/null and b/apps/desktop/src/assets/icons/nightly.png differ diff --git a/apps/desktop/src/assets/icons/pro.png b/apps/desktop/src/assets/icons/pro.png new file mode 100644 index 0000000000..660a801a5e Binary files /dev/null and b/apps/desktop/src/assets/icons/pro.png differ diff --git a/apps/desktop/src/components/settings/general/app-icon.tsx b/apps/desktop/src/components/settings/general/app-icon.tsx new file mode 100644 index 0000000000..b2475f21de --- /dev/null +++ b/apps/desktop/src/components/settings/general/app-icon.tsx @@ -0,0 +1,107 @@ +import { useEffect, useMemo, useState } from "react"; + +import { + type AppIcon, + commands as windowsCommands, +} from "@hypr/plugin-windows"; +import { cn } from "@hypr/utils"; + +import darkIcon from "../../../assets/icons/dark.png"; +import lightIcon from "../../../assets/icons/light.png"; +import nightlyIcon from "../../../assets/icons/nightly.png"; +import proIcon from "../../../assets/icons/pro.png"; +import { useBillingAccess } from "../../../billing"; + +const ICON_OPTIONS: { value: AppIcon; label: string; icon: string }[] = [ + { value: "dark", label: "Dark", icon: darkIcon }, + { value: "light", label: "Light", icon: lightIcon }, + { value: "nightly", label: "Nightly", icon: nightlyIcon }, + { value: "pro", label: "Pro", icon: proIcon }, +]; + +type BuildChannel = "nightly" | "stable" | "staging" | "dev"; + +function getAvailableIconsForTier( + channel: BuildChannel, + isPro: boolean, +): AppIcon[] { + if (channel === "nightly") { + return ["nightly"]; + } + + if (isPro) { + return ["dark", "light", "nightly", "pro"]; + } + + return ["dark", "light"]; +} + +interface AppIconSettingsProps { + value: AppIcon; + onChange: (value: AppIcon) => void; +} + +export function AppIconSettings({ value, onChange }: AppIconSettingsProps) { + const [channel, setChannel] = useState("dev"); + const { isPro } = useBillingAccess(); + + useEffect(() => { + windowsCommands.getBuildChannel().then((ch) => { + setChannel(ch as BuildChannel); + }); + }, []); + + const availableIcons = useMemo( + () => getAvailableIconsForTier(channel, isPro), + [channel, isPro], + ); + + const visibleOptions = useMemo( + () => ICON_OPTIONS.filter((opt) => availableIcons.includes(opt.value)), + [availableIcons], + ); + + useEffect(() => { + if (!availableIcons.includes(value)) { + const defaultIcon = availableIcons[0]; + if (defaultIcon) { + onChange(defaultIcon); + } + } + }, [availableIcons, value, onChange]); + + if (visibleOptions.length <= 1) { + return null; + } + + return ( +
+

App Icon

+

+ Choose your preferred app icon style. Changes apply immediately. +

+
+ {visibleOptions.map((option) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/components/settings/general/index.tsx b/apps/desktop/src/components/settings/general/index.tsx index 74a10effed..28375245bb 100644 --- a/apps/desktop/src/components/settings/general/index.tsx +++ b/apps/desktop/src/components/settings/general/index.tsx @@ -2,10 +2,12 @@ import { LANGUAGES_ISO_639_1 } from "@huggingface/languages"; import { useForm } from "@tanstack/react-form"; import { disable, enable } from "@tauri-apps/plugin-autostart"; +import type { AppIcon } from "@hypr/plugin-windows"; import type { General, GeneralStorage } from "@hypr/store"; import { useConfigValues } from "../../../config/use-config"; import * as main from "../../../store/tinybase/main"; +import { AppIconSettings } from "./app-icon"; import { AppSettingsView } from "./app-settings"; import { MainLanguageView } from "./main-language"; import { Permissions } from "./permissions"; @@ -19,6 +21,7 @@ export function SettingsGeneral() { "telemetry_consent", "ai_language", "spoken_languages", + "app_icon", ] as const); const setPartialValues = main.UI.useSetPartialValuesCallback( @@ -47,6 +50,7 @@ export function SettingsGeneral() { telemetry_consent: value.telemetry_consent, ai_language: value.ai_language, spoken_languages: value.spoken_languages, + app_icon: value.app_icon as AppIcon, }, listeners: { onChange: ({ formApi }) => { @@ -147,6 +151,15 @@ export function SettingsGeneral() { + + {(field) => ( + field.handleChange(val)} + /> + )} + + ); diff --git a/apps/desktop/src/config/registry.ts b/apps/desktop/src/config/registry.ts index 02b3f65b52..3b37265dea 100644 --- a/apps/desktop/src/config/registry.ts +++ b/apps/desktop/src/config/registry.ts @@ -5,6 +5,10 @@ import { commands as localSttCommands, type SupportedSttModel, } from "@hypr/plugin-local-stt"; +import { + type AppIcon, + commands as windowsCommands, +} from "@hypr/plugin-windows"; export type ConfigKey = | "autostart" @@ -21,7 +25,8 @@ export type ConfigKey = | "save_recordings" | "telemetry_consent" | "current_llm_provider" - | "current_llm_model"; + | "current_llm_model" + | "app_icon"; type ConfigValueType = (typeof CONFIG_REGISTRY)[K]["default"]; @@ -154,4 +159,12 @@ export const CONFIG_REGISTRY = { key: "current_llm_model", default: undefined, }, + + app_icon: { + key: "app_icon", + default: "dark" as AppIcon, + sideEffect: async (value: AppIcon, _) => { + await windowsCommands.setAppIcon(value); + }, + }, } satisfies Record; diff --git a/packages/store/src/schema-internal.ts b/packages/store/src/schema-internal.ts index 67cb27be69..5cda6846bd 100644 --- a/packages/store/src/schema-internal.ts +++ b/packages/store/src/schema-internal.ts @@ -7,6 +7,9 @@ import { type ToStorageType, } from "./shared"; +export const appIconSchema = z.enum(["dark", "light", "nightly", "pro"]); +export type AppIcon = z.infer; + export const generalSchema = z.object({ user_id: z.string(), autostart: z.boolean().default(false), @@ -24,6 +27,7 @@ export const generalSchema = z.object({ current_llm_model: z.string().optional(), current_stt_provider: z.string().optional(), current_stt_model: z.string().optional(), + app_icon: appIconSchema.default("dark"), }); export const aiProviderSchema = z @@ -64,6 +68,7 @@ export const internalSchemaForTinybase = { current_llm_model: { type: "string" }, current_stt_provider: { type: "string" }, current_stt_model: { type: "string" }, + app_icon: { type: "string" }, } as const satisfies InferTinyBaseSchema, table: { ai_providers: { diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index c179c9f919..57dfdd2c57 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -62,6 +62,28 @@ async removeFakeWindow(name: string) : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async setAppIcon(iconType: AppIcon) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:windows|set_app_icon", { iconType }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async resetAppIcon() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:windows|reset_app_icon") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async getAvailableIcons() : Promise { + return await TAURI_INVOKE("plugin:windows|get_available_icons"); +}, +async getBuildChannel() : Promise { + return await TAURI_INVOKE("plugin:windows|get_build_channel"); } } @@ -84,6 +106,7 @@ windowDestroyed: "plugin:windows:window-destroyed" /** user-defined types **/ +export type AppIcon = "dark" | "light" | "nightly" | "pro" export type AppWindow = { type: "onboarding" } | { type: "main" } | { type: "settings" } | { type: "auth" } | { type: "chat" } | { type: "devtool" } | { type: "control" } export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> export type MainWindowState = { left_sidebar_expanded: boolean | null; right_panel_expanded: boolean | null } diff --git a/plugins/windows/src/commands.rs b/plugins/windows/src/commands.rs index 818c94cf60..2cf72c1a45 100644 --- a/plugins/windows/src/commands.rs +++ b/plugins/windows/src/commands.rs @@ -1,4 +1,4 @@ -use crate::{events, AppWindow, FakeWindowBounds, OverlayBound, WindowsPluginExt}; +use crate::{events, icon, AppIcon, AppWindow, FakeWindowBounds, OverlayBound, WindowsPluginExt}; #[tauri::command] #[specta::specta] @@ -105,3 +105,37 @@ pub async fn remove_fake_window( ) -> Result<(), String> { remove_bounds(&window, &state, name).await } + +#[tauri::command] +#[specta::specta] +pub fn set_app_icon(icon_type: AppIcon) -> Result<(), String> { + icon::set_app_icon(icon_type) +} + +#[tauri::command] +#[specta::specta] +pub fn reset_app_icon() -> Result<(), String> { + icon::reset_app_icon() +} + +#[tauri::command] +#[specta::specta] +pub fn get_available_icons() -> Vec { + AppIcon::all() +} + +#[tauri::command] +#[specta::specta] +pub fn get_build_channel(app: tauri::AppHandle) -> String { + let identifier = &app.config().identifier; + + if identifier.contains("nightly") { + "nightly".to_string() + } else if identifier.contains("stable") { + "stable".to_string() + } else if identifier.contains("staging") { + "staging".to_string() + } else { + "dev".to_string() + } +} diff --git a/plugins/windows/src/icon.rs b/plugins/windows/src/icon.rs new file mode 100644 index 0000000000..74d73dcfbb --- /dev/null +++ b/plugins/windows/src/icon.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, Default)] +#[serde(rename_all = "snake_case")] +pub enum AppIcon { + #[default] + Dark, + Light, + Nightly, + Pro, +} + +impl AppIcon { + pub fn all() -> Vec { + vec![ + AppIcon::Dark, + AppIcon::Light, + AppIcon::Nightly, + AppIcon::Pro, + ] + } +} + +#[cfg(target_os = "macos")] +pub fn set_app_icon(icon: AppIcon) -> Result<(), String> { + use objc2::rc::Retained; + use objc2_app_kit::{NSApplication, NSImage}; + use objc2_foundation::{MainThreadMarker, NSData}; + + let icon_bytes: &[u8] = match icon { + AppIcon::Dark => include_bytes!("../../../apps/desktop/src-tauri/icons/dynamic/dark.png"), + AppIcon::Light => include_bytes!("../../../apps/desktop/src-tauri/icons/dynamic/light.png"), + AppIcon::Nightly => { + include_bytes!("../../../apps/desktop/src-tauri/icons/dynamic/nightly.png") + } + AppIcon::Pro => include_bytes!("../../../apps/desktop/src-tauri/icons/dynamic/pro.png"), + }; + + let mtm = MainThreadMarker::new().ok_or("Must be called from main thread")?; + + unsafe { + let data = NSData::with_bytes(icon_bytes); + let image: Option> = NSImage::initWithData(NSImage::alloc(), &data); + + if let Some(image) = image { + let app = NSApplication::sharedApplication(mtm); + app.setApplicationIconImage(Some(&image)); + Ok(()) + } else { + Err("Failed to create NSImage from icon data".to_string()) + } + } +} + +#[cfg(not(target_os = "macos"))] +pub fn set_app_icon(_icon: AppIcon) -> Result<(), String> { + Ok(()) +} + +#[cfg(target_os = "macos")] +pub fn reset_app_icon() -> Result<(), String> { + use objc2_app_kit::NSApplication; + use objc2_foundation::MainThreadMarker; + + let mtm = MainThreadMarker::new().ok_or("Must be called from main thread")?; + + unsafe { + let app = NSApplication::sharedApplication(mtm); + app.setApplicationIconImage(None); + } + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +pub fn reset_app_icon() -> Result<(), String> { + Ok(()) +} diff --git a/plugins/windows/src/lib.rs b/plugins/windows/src/lib.rs index 0517976f1c..aa1f0416c6 100644 --- a/plugins/windows/src/lib.rs +++ b/plugins/windows/src/lib.rs @@ -2,12 +2,14 @@ mod commands; mod errors; mod events; mod ext; +mod icon; mod overlay; mod window; pub use errors::*; pub use events::*; pub use ext::*; +pub use icon::*; pub use window::*; pub use overlay::{FakeWindowBounds, OverlayBound}; @@ -54,6 +56,10 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::window_is_exists, commands::set_fake_window_bounds, commands::remove_fake_window, + commands::set_app_icon, + commands::reset_app_icon, + commands::get_available_icons, + commands::get_build_channel, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) }