Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/desktop/src-tauri/icons/dynamic/dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/src-tauri/icons/dynamic/light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/src-tauri/icons/dynamic/nightly.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/src-tauri/icons/dynamic/pro.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/src/assets/icons/dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/src/assets/icons/light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/src/assets/icons/nightly.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/src/assets/icons/pro.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 107 additions & 0 deletions apps/desktop/src/components/settings/general/app-icon.tsx
Original file line number Diff line number Diff line change
@@ -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<BuildChannel>("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 (
<div>
<h2 className="font-semibold mb-4">App Icon</h2>
<p className="text-xs text-neutral-600 mb-4">
Choose your preferred app icon style. Changes apply immediately.
</p>
<div className="grid grid-cols-4 gap-3">
{visibleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all",
value === option.value
? "border-blue-500 bg-blue-50"
: "border-neutral-200 hover:border-neutral-300 hover:bg-neutral-50",
)}
>
<img
src={option.icon}
alt={option.label}
className="w-12 h-12 rounded-lg"
/>
<span className="text-xs font-medium">{option.label}</span>
</button>
))}
</div>
</div>
);
}
13 changes: 13 additions & 0 deletions apps/desktop/src/components/settings/general/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +21,7 @@ export function SettingsGeneral() {
"telemetry_consent",
"ai_language",
"spoken_languages",
"app_icon",
] as const);

const setPartialValues = main.UI.useSetPartialValuesCallback(
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -147,6 +151,15 @@ export function SettingsGeneral() {
</div>
</div>

<form.Field name="app_icon">
{(field) => (
<AppIconSettings
value={field.state.value}
onChange={(val) => field.handleChange(val)}
/>
)}
</form.Field>

<Permissions />
</div>
);
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/config/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -21,7 +25,8 @@ export type ConfigKey =
| "save_recordings"
| "telemetry_consent"
| "current_llm_provider"
| "current_llm_model";
| "current_llm_model"
| "app_icon";

type ConfigValueType<K extends ConfigKey> =
(typeof CONFIG_REGISTRY)[K]["default"];
Expand Down Expand Up @@ -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<ConfigKey, ConfigDefinition>;
5 changes: 5 additions & 0 deletions packages/store/src/schema-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
type ToStorageType,
} from "./shared";

export const appIconSchema = z.enum(["dark", "light", "nightly", "pro"]);
export type AppIcon = z.infer<typeof appIconSchema>;

export const generalSchema = z.object({
user_id: z.string(),
autostart: z.boolean().default(false),
Expand All @@ -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
Expand Down Expand Up @@ -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<typeof generalSchema>,
table: {
ai_providers: {
Expand Down
23 changes: 23 additions & 0 deletions plugins/windows/js/bindings.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@ async removeFakeWindow(name: string) : Promise<Result<null, string>> {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setAppIcon(iconType: AppIcon) : Promise<Result<null, string>> {
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<Result<null, string>> {
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<AppIcon[]> {
return await TAURI_INVOKE("plugin:windows|get_available_icons");
},
async getBuildChannel() : Promise<string> {
return await TAURI_INVOKE("plugin:windows|get_build_channel");
}
}

Expand All @@ -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 }
Expand Down
36 changes: 35 additions & 1 deletion plugins/windows/src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{events, AppWindow, FakeWindowBounds, OverlayBound, WindowsPluginExt};
use crate::{events, icon, AppIcon, AppWindow, FakeWindowBounds, OverlayBound, WindowsPluginExt};

#[tauri::command]
#[specta::specta]
Expand Down Expand Up @@ -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> {
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()
}
}
79 changes: 79 additions & 0 deletions plugins/windows/src/icon.rs
Original file line number Diff line number Diff line change
@@ -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<AppIcon> {
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<Retained<NSImage>> = 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(())
}
6 changes: 6 additions & 0 deletions plugins/windows/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -54,6 +56,10 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
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)
}
Expand Down
Loading