app settings

This commit is contained in:
Hanashi 2026-08-29 23:28:15 -04:00
parent da1fdc25f8
commit 96ec5caa5f
29 changed files with 363 additions and 67 deletions

View File

@ -42,6 +42,11 @@ pub enum TauriEventName {
#[serde(rename = "credits_balance_changed_event")]
CreditsBalanceChangedEvent,
/// Informational event:
/// The app preferences were saved; re-read them.
#[serde(rename = "app_preferences_changed_event")]
AppPreferencesChangedEvent,
/// Informational event:
/// Subscription was changed (but we don't know the details - upgrade, cancel, etc.)
#[serde(rename = "subscription_plan_changed_event")]
@ -106,6 +111,7 @@ impl TauriEventName {
Self::GenerationCompleteEvent => "generation-complete-event",
Self::GenerationFailedEvent => "generation-failed-event",
Self::CreditsBalanceChangedEvent => "credits_balance_changed_event",
Self::AppPreferencesChangedEvent => "app_preferences_changed_event",
Self::SubscriptionPlanChangedEvent => "subscription_plan_changed_event",
Self::TextToImageGenerationCompleteEvent => "text_to_image_generation_complete_event",
Self::ObjectGenerationCompleteEvent => "object_generation_complete_event",
@ -126,6 +132,7 @@ impl TauriEventName {
"generation-complete-event" => Ok(Self::GenerationCompleteEvent),
"generation-failed-event" => Ok(Self::GenerationFailedEvent),
"credits_balance_changed_event" => Ok(Self::CreditsBalanceChangedEvent),
"app_preferences_changed_event" => Ok(Self::AppPreferencesChangedEvent),
"subscription_plan_changed_event" => Ok(Self::SubscriptionPlanChangedEvent),
"text_to_image_generation_complete_event" => Ok(Self::TextToImageGenerationCompleteEvent),
"object_generation_complete_event" => Ok(Self::ObjectGenerationCompleteEvent),
@ -149,6 +156,7 @@ impl TauriEventName {
Self::GenerationCompleteEvent,
Self::GenerationFailedEvent,
Self::CreditsBalanceChangedEvent,
Self::AppPreferencesChangedEvent,
Self::SubscriptionPlanChangedEvent,
Self::TextToImageGenerationCompleteEvent,
Self::ObjectGenerationCompleteEvent,
@ -178,6 +186,7 @@ mod tests {
assert_serialization(TauriEventName::GenerationCompleteEvent, "generation-complete-event");
assert_serialization(TauriEventName::GenerationFailedEvent, "generation-failed-event");
assert_serialization(TauriEventName::CreditsBalanceChangedEvent, "credits_balance_changed_event");
assert_serialization(TauriEventName::AppPreferencesChangedEvent, "app_preferences_changed_event");
assert_serialization(TauriEventName::SubscriptionPlanChangedEvent, "subscription_plan_changed_event");
assert_serialization(TauriEventName::TextToImageGenerationCompleteEvent, "text_to_image_generation_complete_event");
assert_serialization(TauriEventName::ObjectGenerationCompleteEvent, "object_generation_complete_event");
@ -197,6 +206,7 @@ mod tests {
assert_eq!(TauriEventName::GenerationCompleteEvent.to_str(), "generation-complete-event");
assert_eq!(TauriEventName::GenerationFailedEvent.to_str(), "generation-failed-event");
assert_eq!(TauriEventName::CreditsBalanceChangedEvent.to_str(), "credits_balance_changed_event");
assert_eq!(TauriEventName::AppPreferencesChangedEvent.to_str(), "app_preferences_changed_event");
assert_eq!(TauriEventName::SubscriptionPlanChangedEvent.to_str(), "subscription_plan_changed_event");
assert_eq!(TauriEventName::TextToImageGenerationCompleteEvent.to_str(), "text_to_image_generation_complete_event");
assert_eq!(TauriEventName::ObjectGenerationCompleteEvent.to_str(), "object_generation_complete_event");
@ -215,6 +225,7 @@ mod tests {
assert_eq!(TauriEventName::from_str("generation-complete-event").unwrap(), TauriEventName::GenerationCompleteEvent);
assert_eq!(TauriEventName::from_str("generation-failed-event").unwrap(), TauriEventName::GenerationFailedEvent);
assert_eq!(TauriEventName::from_str("credits_balance_changed_event").unwrap(), TauriEventName::CreditsBalanceChangedEvent);
assert_eq!(TauriEventName::from_str("app_preferences_changed_event").unwrap(), TauriEventName::AppPreferencesChangedEvent);
assert_eq!(TauriEventName::from_str("subscription_plan_changed_event").unwrap(), TauriEventName::SubscriptionPlanChangedEvent);
assert_eq!(TauriEventName::from_str("text_to_image_generation_complete_event").unwrap(), TauriEventName::TextToImageGenerationCompleteEvent);
assert_eq!(TauriEventName::from_str("object_generation_complete_event").unwrap(), TauriEventName::ObjectGenerationCompleteEvent);
@ -230,12 +241,13 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = TauriEventName::all_variants();
assert_eq!(variants.len(), 15);
assert_eq!(variants.len(), 16);
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueSuccessEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueFailureEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationCompleteEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationFailedEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::CreditsBalanceChangedEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::AppPreferencesChangedEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::SubscriptionPlanChangedEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::TextToImageGenerationCompleteEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::ObjectGenerationCompleteEvent));

View File

@ -1,4 +1,8 @@
pub mod get_app_preferences_command;
pub mod load_custom_sound_command;
pub mod update_app_preference_command;
pub mod update_prompt_preference_command;
pub mod update_sound_preference_command;
// Private
mod notify_app_preferences_changed;

View File

@ -0,0 +1,9 @@
use crate::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::events::functional_events::app_preferences_changed_event::AppPreferencesChangedEvent;
use tauri::AppHandle;
/// Tell the frontend the preferences changed so it drops its cached copy.
/// Every command that writes preferences calls this after a successful save.
pub fn notify_app_preferences_changed(app: &AppHandle) {
AppPreferencesChangedEvent {}.send_infallible(app);
}

View File

@ -1,3 +1,4 @@
use crate::commands::service::app_preferences::notify_app_preferences_changed::notify_app_preferences_changed;
use crate::state::app_preferences::app_preferences::AppPreferences;
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::app_preferences::settings::preferred_download_directory::PreferredDownloadDirectory;
@ -6,7 +7,7 @@ use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
use tauri::State;
use tauri::{AppHandle, State};
/// One preference change, typed per preference. The frontend sends
/// `{ "preference": "<name>", "value": <value> }`.
@ -29,6 +30,7 @@ pub struct UpdateAppPreferencesResponse {
#[tauri::command]
pub async fn update_app_preferences_command(
request: UpdateAppPreferencesRequest,
app: AppHandle,
app_prefs: State<'_, AppPreferencesManager>,
) -> Result<UpdateAppPreferencesResponse, String> {
info!("update_app_preferences_command called: {:?}", request);
@ -38,6 +40,8 @@ pub async fn update_app_preferences_command(
format!("Error updating app preferences: {:?}", err)
})?;
notify_app_preferences_changed(&app);
Ok(UpdateAppPreferencesResponse { success: true })
}

View File

@ -0,0 +1,58 @@
use crate::commands::service::app_preferences::notify_app_preferences_changed::notify_app_preferences_changed;
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::app_preferences::settings::app_prompt_preferences::AppPromptPreferences;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
/// One prompt-box preference change. The frontend sends
/// `{ "preference": "<name>", "value": <value> }`.
#[derive(Deserialize, Debug)]
#[serde(tag = "preference", content = "value", rename_all = "snake_case")]
pub enum UpdatePromptPreferenceRequest {
EnterToGenerate(bool),
}
/// The full prompt preferences after the change.
#[derive(Serialize)]
pub struct UpdatePromptPreferenceResponse {
pub prompt: AppPromptPreferences,
}
#[tauri::command]
pub async fn update_prompt_preference_command(
request: UpdatePromptPreferenceRequest,
app: AppHandle,
app_prefs: State<'_, AppPreferencesManager>,
) -> Result<UpdatePromptPreferenceResponse, String> {
info!("update_prompt_preference_command called: {:?}", request);
let prefs = app_prefs
.update(|prefs| match request {
UpdatePromptPreferenceRequest::EnterToGenerate(enabled) => prefs.prompt.enter_to_generate = enabled,
})
.map_err(|err| {
error!("Error updating prompt preference: {:?}", err);
format!("Error updating prompt preference: {:?}", err)
})?;
notify_app_preferences_changed(&app);
Ok(UpdatePromptPreferenceResponse { prompt: prefs.prompt })
}
#[cfg(test)]
mod tests {
use super::*;
/// Pin the wire format the frontend sends (`UpdatePromptPreference.ts`).
#[test]
fn decodes_enter_to_generate() {
assert!(matches!(
serde_json::from_str::<UpdatePromptPreferenceRequest>(r#"{"preference":"enter_to_generate","value":false}"#).unwrap(),
UpdatePromptPreferenceRequest::EnterToGenerate(false),
));
assert!(serde_json::from_str::<UpdatePromptPreferenceRequest>(r#"{"preference":"enter_to_generate","value":"yes"}"#).is_err());
assert!(serde_json::from_str::<UpdatePromptPreferenceRequest>(r#"{"preference":"tab_to_generate","value":true}"#).is_err());
}
}

View File

@ -1,9 +1,10 @@
use crate::commands::service::app_preferences::notify_app_preferences_changed::notify_app_preferences_changed;
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::app_preferences::settings::app_sound_file::{optional_sound, AppSoundFile};
use crate::state::app_preferences::settings::app_sound_preferences::{AppSoundEvent, AppSoundPreferences};
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use tauri::State;
use tauri::{AppHandle, State};
/// Change the sound for one event.
///
@ -35,6 +36,7 @@ pub struct UpdateSoundPreferenceResponse {
#[tauri::command]
pub async fn update_sound_preference_command(
request: UpdateSoundPreferenceRequest,
app: AppHandle,
app_prefs: State<'_, AppPreferencesManager>,
) -> Result<UpdateSoundPreferenceResponse, String> {
info!("update_sound_preference_command called: {:?}", request);
@ -56,6 +58,8 @@ pub async fn update_sound_preference_command(
format!("Error updating sound preference: {:?}", err)
})?;
notify_app_preferences_changed(&app);
Ok(UpdateSoundPreferenceResponse { sounds: prefs.sounds })
}

View File

@ -0,0 +1,14 @@
use crate::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent};
use artcraft_client::enums::tauri::ux::tauri_event_name::TauriEventName;
use serde_derive::Serialize;
/// The app preferences were saved (by any command). Carries no payload; the
/// frontend should re-read them via `get_app_preferences_command`.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AppPreferencesChangedEvent {}
impl BasicSendableEvent for AppPreferencesChangedEvent {
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::AppPreferencesChangedEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success;
}

View File

@ -1,3 +1,4 @@
pub mod app_preferences_changed_event;
pub mod credits_balance_changed_event;
pub mod gaussian_generation_complete_event;
pub mod object_generation_complete_event;

View File

@ -25,6 +25,7 @@ use crate::commands::credentials::refresh_grok_statsig_command::refresh_grok_sta
use crate::commands::service::app_preferences::get_app_preferences_command::get_app_preferences_command;
use crate::commands::service::app_preferences::load_custom_sound_command::load_custom_sound_command;
use crate::commands::service::app_preferences::update_app_preference_command::update_app_preferences_command;
use crate::commands::service::app_preferences::update_prompt_preference_command::update_prompt_preference_command;
use crate::commands::service::app_preferences::update_sound_preference_command::update_sound_preference_command;
use crate::commands::cost_estimate::estimate_audio_cost_command::estimate_audio_cost_command;
use crate::commands::cost_estimate::estimate_image_cost_command::estimate_image_cost_command;
@ -228,6 +229,7 @@ pub fn run() {
platform_info_command,
tasks_nuke_all_command,
update_app_preferences_command,
update_prompt_preference_command,
update_sound_preference_command,
]);

View File

@ -1,5 +1,6 @@
use crate::state::app_preferences::legacy_json_preferences::LegacyJsonPreferences;
use crate::state::app_preferences::settings::app_download_preferences::AppDownloadPreferences;
use crate::state::app_preferences::settings::app_prompt_preferences::AppPromptPreferences;
use crate::state::app_preferences::settings::app_sound_preferences::AppSoundPreferences;
use crate::state::data_dir::app_data_root::AppDataRoot;
use errors::AnyhowResult;
@ -10,7 +11,8 @@ use std::path::Path;
/// File format version (not semver).
/// - 1..=3: the legacy JSON layout (see `legacy_json_preferences`).
/// - 4: TOML, grouped into `[sounds]` / `[downloads]` tables.
const CURRENT_VERSION: u32 = 4;
/// - 5: added the `[prompt]` table.
const CURRENT_VERSION: u32 = 5;
/// User-adjustable app preferences, persisted as `settings/app_preferences.toml`.
///
@ -26,6 +28,9 @@ const CURRENT_VERSION: u32 = 4;
/// [downloads]
/// preferred_download_filename = "artcraft_convention"
///
/// [prompt]
/// enter_to_generate = true
///
/// [downloads.preferred_download_directory]
/// system = "downloads"
/// ```
@ -38,6 +43,7 @@ pub struct AppPreferences {
pub version: u32,
pub sounds: AppSoundPreferences,
pub downloads: AppDownloadPreferences,
pub prompt: AppPromptPreferences,
}
impl Default for AppPreferences {
@ -46,6 +52,7 @@ impl Default for AppPreferences {
version: CURRENT_VERSION,
sounds: AppSoundPreferences::default(),
downloads: AppDownloadPreferences::default(),
prompt: AppPromptPreferences::default(),
}
}
}
@ -123,11 +130,12 @@ mod tests {
#[test]
fn defaults_serialize_to_nested_toml() {
let toml = toml::to_string_pretty(&AppPreferences::default()).unwrap();
assert!(toml.starts_with("version = 4\n"), "{toml}");
assert!(toml.starts_with("version = 5\n"), "{toml}");
assert!(toml.contains("[sounds]\nplay_sounds = true\n"), "{toml}");
assert!(toml.contains("enqueue_success = \"done\""), "{toml}");
assert!(toml.contains("[downloads]\n"), "{toml}");
assert!(toml.contains("[downloads.preferred_download_directory]\nsystem = \"downloads\""), "{toml}");
assert!(toml.contains("[prompt]\nenter_to_generate = true"), "{toml}");
}
#[test]
@ -141,6 +149,7 @@ mod tests {
prefs.sounds.generation_success = Some(AppSoundFile::CustomWav("/tmp/ding.wav".into()));
prefs.downloads.preferred_download_directory = PreferredDownloadDirectory::Custom("/tmp/out".into());
prefs.downloads.preferred_download_filename = PreferredDownloadFilename::Custom("{model}_{date}".into());
prefs.prompt.enter_to_generate = false;
prefs.save_to_file(&path).unwrap();
let loaded = AppPreferences::load_from_file(&path).unwrap();
@ -153,6 +162,7 @@ mod tests {
assert!(!prefs.sounds.play_sounds);
assert_eq!(prefs.sounds.enqueue_success, Some(AppSoundFile::Done));
assert_eq!(prefs.downloads, AppPreferences::default().downloads);
assert!(prefs.prompt.enter_to_generate, "prompt table absent -> default");
}
#[test]

View File

@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
/// How the prompt boxes behave (image, video, mesh, splat, ...).
///
/// Missing fields in an older preferences file fall back to the defaults.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AppPromptPreferences {
/// When on, Enter submits the prompt and Shift+Enter inserts a newline.
/// When off, both insert a newline and only the button submits.
pub enter_to_generate: bool,
}
impl Default for AppPromptPreferences {
fn default() -> Self {
Self {
enter_to_generate: true,
}
}
}

View File

@ -1,4 +1,5 @@
pub mod app_download_preferences;
pub mod app_prompt_preferences;
pub mod app_sound_file;
pub mod app_sound_preferences;
pub mod preferred_download_directory;

View File

@ -20,6 +20,7 @@ import {
ActionReminderModal,
} from "@storyteller/ui-action-reminder-modal";
import {
useAppPreferencesSync,
useFlashFileDownloadErrorEvent,
useFlashUserInputErrorEvent,
useGenerationCompleteEvent,
@ -54,6 +55,9 @@ export const MainApp = () => {
// Tauri event listeners. Must always be mounted so generation/upload
// completions are surfaced no matter which tab the user is on.
// Keep the app-preferences cache (Enter-to-generate, sounds, ...) in sync.
useAppPreferencesSync();
useGenerationEnqueueSuccessEvent();
useGenerationEnqueueFailureEvent();
useGenerationCompleteEvent();

View File

@ -11,7 +11,7 @@ import {
import { createPortal } from "react-dom";
import { twMerge } from "tailwind-merge";
import { Music, User, Video } from "lucide-react";
import { useEnterToGenerateStore } from "./promptStore";
import { useEnterToGenerate } from "@storyteller/tauri-api";
import { MentionChipMenu } from "./MentionChipMenu";
import { DeckPreviewModal } from "./deck/DeckCard";
@ -537,8 +537,8 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
// right after a mention label, which detectMention would read as a fresh
// "@..." trigger and reopen the dropdown over the already-confirmed chip.
const suppressMentionDetect = useRef(false);
const enterToGenerateStore = useEnterToGenerateStore((s) => s.enabled);
const enterToGenerate = enterToGenerateProp ?? enterToGenerateStore;
const enterToGeneratePreference = useEnterToGenerate();
const enterToGenerate = enterToGenerateProp ?? enterToGeneratePreference;
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,

View File

@ -1,3 +1,4 @@
import { useEnterToGenerate } from "@storyteller/tauri-api";
import { useMemo, useRef, useState, ReactNode } from "react";
import { toast } from "@storyteller/ui-toaster";
import {
@ -23,7 +24,6 @@ import {
} from "@storyteller/model-list";
import {
usePromptAudioStore,
useEnterToGenerateStore,
type RefAudio,
} from "./promptStore";
import { useAutoGrowEditorHeight } from "./useAutoGrowEditorHeight";
@ -127,7 +127,7 @@ export const PromptBoxAudio = ({
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const enterToGenerate = useEnterToGenerate();
// Audio library picker (the "From library" button in the reference row).
const [isAudioLibraryOpen, setIsAudioLibraryOpen] = useState(false);

View File

@ -1,3 +1,4 @@
import { useEnterToGenerate } from "@storyteller/tauri-api";
import { useState, useRef, useEffect, useMemo, ReactNode } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { UploaderState } from "@storyteller/common";
@ -13,7 +14,6 @@ import type { UploadMediaFn } from "@storyteller/api";
import {
usePromptImageStore,
RefImage,
useEnterToGenerateStore,
} from "./promptStore";
import { useAutoGrowEditorHeight } from "./useAutoGrowEditorHeight";
import {
@ -131,7 +131,7 @@ export const PromptBoxImage = ({
const referenceImages = usePromptImageStore((s) => s.referenceImages);
const setReferenceImages = usePromptImageStore((s) => s.setReferenceImages);
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const enterToGenerate = useEnterToGenerate();
const maxImagePromptCount = Math.max(
1,

View File

@ -1,3 +1,4 @@
import { useEnterToGenerate } from "@storyteller/tauri-api";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
@ -27,7 +28,6 @@ import {
VideoInputMode,
useCharactersStore,
StoredCharacter,
useEnterToGenerateStore,
} from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import type { UploadImageFn } from "./ImagePromptRow";
@ -186,7 +186,7 @@ export const PromptBoxVideo = ({
const setInputMode = usePromptVideoStore((s) => s.setInputMode);
const generationCount = usePromptVideoStore((s) => s.generationCount);
const setGenerationCount = usePromptVideoStore((s) => s.setGenerationCount);
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const enterToGenerate = useEnterToGenerate();
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);

View File

@ -293,47 +293,8 @@ export const usePromptEditStore = create<PromptEditStore>()((set) => ({
setResolution: (resolution) => set({ resolution }),
}));
// ----- Enter-to-Generate Preference Store -----
// Controls how the Enter key behaves inside prompt boxes.
// false (default): Both Enter and Shift+Enter insert a newline; only the button submits.
// true: Enter submits; Shift+Enter inserts a newline.
// Persisted to localStorage so the choice survives reloads.
const ENTER_TO_GENERATE_STORAGE_KEY = "artcraft_enter_to_generate";
const readEnterToGenerate = (): boolean => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(ENTER_TO_GENERATE_STORAGE_KEY) === "true";
} catch {
return false;
}
};
const writeEnterToGenerate = (enabled: boolean) => {
if (typeof window === "undefined") return;
try {
if (enabled) {
window.localStorage.setItem(ENTER_TO_GENERATE_STORAGE_KEY, "true");
} else {
window.localStorage.removeItem(ENTER_TO_GENERATE_STORAGE_KEY);
}
} catch {
// ignore storage failures
}
};
interface EnterToGenerateStore {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
}
export const useEnterToGenerateStore = create<EnterToGenerateStore>()((set) => ({
enabled: readEnterToGenerate(),
setEnabled: (enabled) => {
writeEnterToGenerate(enabled);
set({ enabled });
},
}));
// NB: The Enter-to-generate preference lives in the backend app preferences;
// read it with `useEnterToGenerate()` from @storyteller/tauri-api.
// ----- Characters Store -----
export interface StoredCharacter {

View File

@ -1,12 +1,31 @@
import { useEnterToGenerateStore } from "@storyteller/ui-promptbox";
import {
PromptPreferenceName,
UpdatePromptPreference,
useAppPreferencesStore,
useEnterToGenerate,
} from "@storyteller/tauri-api";
import { Switch } from "@storyteller/ui-switch";
import { SettingsRow } from "./SettingsRow";
interface MiscSettingsPaneProps {}
export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
const enterToGenerate = useEnterToGenerate();
const refreshPreferences = useAppPreferencesStore((s) => s.refresh);
const setEnterToGenerate = async (enabled: boolean) => {
try {
await UpdatePromptPreference({
preference: PromptPreferenceName.EnterToGenerate,
value: enabled,
});
} catch (err) {
console.error("Could not save Enter-to-generate preference:", err);
}
// The backend also broadcasts app_preferences_changed_event; refreshing
// here just makes the switch settle without waiting on it.
await refreshPreferences();
};
return (
<div className="text-base-fg">

View File

@ -1,9 +1,10 @@
import {
AppSoundEvent,
AppSoundFile,
GetAppPreferences,
SILENT_SOUND,
getCachedAppPreferences,
isCustomWavSound,
useAppPreferencesStore,
} from "@storyteller/tauri-api";
import { CustomSoundPlayer } from "./CustomSoundPlayer";
import { SoundEffect } from "./SoundEffect";
@ -98,13 +99,11 @@ export class SoundManager {
static async playGenerationFailure() { await this.playEvent("generation_failure"); }
private static async playEvent(event: AppSoundEvent) {
let sounds;
try {
sounds = (await GetAppPreferences()).preferences?.sounds;
} catch (err) {
console.warn("Could not read sound preferences; not playing a sound:", err);
return;
// Read the synced cache; load it once if this fires before the app root did.
if (getCachedAppPreferences() === undefined) {
await useAppPreferencesStore.getState().refresh();
}
const sounds = getCachedAppPreferences()?.sounds;
if (!sounds?.play_sounds) return;
await this.playSound(sounds[event], event);
}

View File

@ -15,6 +15,7 @@
}
},
"dependencies": {
"@tauri-apps/api": "^2.11.0"
"@tauri-apps/api": "^2.11.0",
"zustand": "^5.0.5"
}
}

View File

@ -32,6 +32,8 @@ export * from "./lib/providers/Provider.js";
export * from "./lib/settings/GetAppPreferences.js";
export * from "./lib/settings/UpdateAppPreference.js";
export * from "./lib/settings/UpdateSoundPreference.js";
export * from "./lib/settings/UpdatePromptPreference.js";
export * from "./lib/settings/appPreferencesStore.js";
export * from "./lib/settings/LoadCustomSound.js";
export * from "./lib/sora/CheckSoraSession.js";
export * from "./lib/sora/LogoutSoraSession.js";

View File

@ -8,6 +8,13 @@ export interface GetAppPreferencesResult {
export interface AppPreferencesPayload {
sounds: AppSoundPreferences,
downloads: AppDownloadPreferences,
prompt: AppPromptPreferences,
}
export interface AppPromptPreferences {
// When on, Enter submits the prompt and Shift+Enter inserts a newline.
// When off, both insert a newline and only the button submits.
enter_to_generate: boolean,
}
export interface AppSoundPreferences {

View File

@ -0,0 +1,27 @@
import { invoke } from "@tauri-apps/api/core";
import { AppPromptPreferences } from "./GetAppPreferences";
export enum PromptPreferenceName {
EnterToGenerate = "enter_to_generate",
}
export interface UpdatePromptPreferenceRequest {
preference: PromptPreferenceName,
value: boolean,
}
export interface UpdatePromptPreferenceResult {
// The full prompt preferences after the change.
prompt: AppPromptPreferences,
}
// Change one prompt-box preference.
export const UpdatePromptPreference = async (request: UpdatePromptPreferenceRequest) : Promise<UpdatePromptPreferenceResult> => {
let result = await invoke("update_prompt_preference_command", {
request: {
preference: request.preference,
value: request.value,
}
});
return (result as UpdatePromptPreferenceResult);
}

View File

@ -0,0 +1,63 @@
import { create } from "zustand";
import { AppPreferencesPayload, GetAppPreferences } from "./GetAppPreferences";
// ── App preferences cache ──
//
// The single frontend copy of the backend's app preferences. Hot paths (e.g.
// deciding what Enter does in a prompt box) read it synchronously; nothing
// calls the backend per keypress.
//
// Kept fresh by `useAppPreferencesSync` (in @storyteller/tauri-events), which
// loads it on mount, re-loads when the backend emits
// `app_preferences_changed_event` (sent after every preference write), and
// re-loads on window focus / when older than STALE_AFTER_MS as a safety net.
// Code that writes a preference can also call `refresh()` directly.
export const STALE_AFTER_MS = 60_000;
interface AppPreferencesStore {
preferences: AppPreferencesPayload | undefined;
// When `preferences` was last loaded (ms since epoch); undefined if never.
loadedAt: number | undefined;
// Re-read from the backend. Failures are logged and leave the cache as-is.
refresh: () => Promise<void>;
// Re-read only if never loaded or older than STALE_AFTER_MS.
refreshIfStale: () => Promise<void>;
}
export const useAppPreferencesStore = create<AppPreferencesStore>()((set, get) => ({
preferences: undefined,
loadedAt: undefined,
refresh: async () => {
try {
const result = await GetAppPreferences();
set({ preferences: result.preferences, loadedAt: Date.now() });
} catch (err) {
console.warn("Could not load app preferences; keeping the cached copy:", err);
}
},
refreshIfStale: async () => {
const { loadedAt, refresh } = get();
if (loadedAt === undefined || Date.now() - loadedAt > STALE_AFTER_MS) {
await refresh();
}
},
}));
// ── Selectors ──
//
// Each falls back to the backend's default until the first load completes.
export const selectEnterToGenerate = (s: AppPreferencesStore): boolean =>
s.preferences?.prompt.enter_to_generate ?? true;
// Whether Enter submits the prompt (Shift+Enter inserts a newline). Reactive
// and synchronous — safe to read in key handlers.
export const useEnterToGenerate = (): boolean =>
useAppPreferencesStore(selectEnterToGenerate);
// Non-hook access for code outside React (e.g. sound playback).
export const getCachedAppPreferences = (): AppPreferencesPayload | undefined =>
useAppPreferencesStore.getState().preferences;

View File

@ -1,5 +1,7 @@
export * from "./lib/common/BasicEventWrapper";
export * from "./lib/events/functional/AppPreferencesChangedEvent";
export * from "./lib/events/functional/CreditsBalanceChangedEvent";
export * from "./lib/sync/useAppPreferencesSync";
export * from "./lib/events/functional/ObjectGenerationCompleteEvent";
export * from "./lib/events/functional/RefreshAccountStateEvent";
export * from "./lib/events/functional/ShowProviderBillingModalEvent";

View File

@ -0,0 +1,38 @@
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { BasicEventWrapper } from "../../common/BasicEventWrapper";
import { useEffect } from "react";
const EVENT_NAME: string = "app_preferences_changed_event";
// The backend saved the app preferences (any command). No payload; re-read.
export interface AppPreferencesChangedEvent {
}
export const useAppPreferencesChangedEvent = (
asyncCallback: (event: AppPreferencesChangedEvent) => Promise<void>
) => {
useEffect(() => {
let isUnmounted = false;
let unlisten: Promise<UnlistenFn>;
const setup = async () => {
unlisten = listen<BasicEventWrapper<AppPreferencesChangedEvent>>(
EVENT_NAME,
async (wrappedEvent) => {
await asyncCallback(wrappedEvent.payload.data);
}
);
if (isUnmounted) {
unlisten.then((f) => f()); // Unsubscribe if unmounted early.
}
};
setup();
return () => {
isUnmounted = true;
unlisten.then((f) => f());
};
}, []);
};

View File

@ -0,0 +1,33 @@
import { useEffect } from "react";
import { STALE_AFTER_MS, useAppPreferencesStore } from "@storyteller/tauri-api";
import { useAppPreferencesChangedEvent } from "../events/functional/AppPreferencesChangedEvent";
// Keeps the frontend's app-preferences cache (`useAppPreferencesStore`) in
// sync with the backend. Mount ONCE near the app root.
//
// - loads on mount
// - reloads immediately when the backend emits `app_preferences_changed_event`
// (every preference write does), so a change made in Settings — or from
// another window — is visible everywhere at once
// - reloads on window focus and on a stale timer as a safety net
export const useAppPreferencesSync = () => {
const refresh = useAppPreferencesStore((s) => s.refresh);
const refreshIfStale = useAppPreferencesStore((s) => s.refreshIfStale);
useEffect(() => {
refresh();
const onFocus = () => { refreshIfStale(); };
window.addEventListener("focus", onFocus);
const timer = window.setInterval(refreshIfStale, STALE_AFTER_MS);
return () => {
window.removeEventListener("focus", onFocus);
window.clearInterval(timer);
};
}, [refresh, refreshIfStale]);
useAppPreferencesChangedEvent(async () => {
await refresh();
});
};

View File

@ -737,7 +737,8 @@
"name": "@storyteller/tauri-api",
"version": "0.0.1",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
"@tauri-apps/api": "^2.11.0",
"zustand": "^5.0.5"
}
},
"libs/tauri-events": {