Add persistent settings storage for library_remote

Replace env-var-only LIBRARY_REMOTE configuration with disk-persisted
settings. The setting can now be edited in the dashboard Settings page,
with LIBRARY_REMOTE env var serving as initial default when no settings
file exists.

Changes:
- Add src/settings.rs for settings storage with JSON persistence
- Add src/api/settings.rs for settings API endpoints
- Update dashboard Settings page with editable library remote field
- Update library-unavailable component to link to Settings page
- Update documentation to recommend Settings page method
This commit is contained in:
Thomas Marchand
2026-01-17 18:01:23 +00:00
parent 38df08deae
commit 6389dccfc3
13 changed files with 514 additions and 32 deletions

View File

@@ -21,6 +21,8 @@ OPENCODE_PERMISSIVE=true
# =============================================================================
WORKING_DIR=/root
LIBRARY_PATH=/root/.openagent/library
# Library remote URL - can also be configured via dashboard Settings page (preferred).
# This env var is used as the initial default when no settings file exists.
# LIBRARY_REMOTE=git@github.com:your-org/agent-library.git
# =============================================================================

View File

@@ -391,8 +391,15 @@ git push -u origin HEAD:main
### 5.2 Configure Open Agent to use it
**Option A: Via Dashboard Settings (recommended)**
After starting Open Agent, go to **Settings** in the dashboard and set the Library Remote URL.
This is the preferred method as it persists the setting to disk and allows runtime updates without restart.
**Option B: Via environment variable (initial default)**
Set in `/etc/open_agent/open_agent.env`:
- `LIBRARY_REMOTE=git@github.com:<your-org>/<your-library-repo>.git`
- `LIBRARY_REMOTE=git@github.com:<your-org>/<your-library-repo>.git` (used as initial default if not configured in Settings)
- optional: `LIBRARY_PATH=/root/.openagent/library`
---
@@ -424,6 +431,7 @@ PORT=3000
# Default filesystem root for Open Agent (agent still has full system access)
WORKING_DIR=/root
LIBRARY_PATH=/root/.openagent/library
# Library remote (optional, can also be set via dashboard Settings page)
LIBRARY_REMOTE=git@github.com:<your-org>/<your-library-repo>.git
# Auth (set DEV_MODE=false on real deployments)

View File

@@ -14,6 +14,8 @@ import {
setDefaultAIProvider,
AIProvider,
AIProviderTypeInfo,
getSettings,
updateLibraryRemote,
} from '@/lib/api';
import {
Server,
@@ -28,6 +30,8 @@ import {
ExternalLink,
Loader,
Key,
Check,
X,
} from 'lucide-react';
import { readSavedSettings, writeSavedSettings } from '@/lib/settings';
import { cn } from '@/lib/utils';
@@ -92,6 +96,11 @@ export default function SettingsPage() {
enabled?: boolean;
}>({});
// Library remote edit state
const [editingLibraryRemote, setEditingLibraryRemote] = useState(false);
const [libraryRemoteValue, setLibraryRemoteValue] = useState('');
const [savingLibraryRemote, setSavingLibraryRemote] = useState(false);
// SWR: fetch health status
const { data: health, isLoading: healthLoading, mutate: mutateHealth } = useSWR(
'health',
@@ -113,6 +122,13 @@ export default function SettingsPage() {
{ revalidateOnFocus: false, fallbackData: defaultProviderTypes }
);
// SWR: fetch server settings
const { data: serverSettings, mutate: mutateSettings } = useSWR(
'settings',
getSettings,
{ revalidateOnFocus: false }
);
// Check if there are unsaved changes
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
@@ -278,6 +294,49 @@ export default function SettingsPage() {
setEditForm({});
};
// Library remote handlers
const handleStartEditLibraryRemote = () => {
setLibraryRemoteValue(serverSettings?.library_remote || '');
setEditingLibraryRemote(true);
};
const handleCancelEditLibraryRemote = () => {
setEditingLibraryRemote(false);
setLibraryRemoteValue('');
};
const handleSaveLibraryRemote = async () => {
setSavingLibraryRemote(true);
try {
const trimmed = libraryRemoteValue.trim();
const result = await updateLibraryRemote(trimmed || null);
// Revalidate both settings and health (which also exposes library_remote)
mutateSettings();
mutateHealth();
setEditingLibraryRemote(false);
if (result.library_reinitialized) {
if (result.library_error) {
toast.error(`Library saved but failed to initialize: ${result.library_error}`);
} else if (result.library_remote) {
toast.success('Library remote updated and reinitialized');
} else {
toast.success('Library remote cleared');
}
} else {
toast.success('Library remote saved (no change)');
}
} catch (err) {
toast.error(
`Failed to save: ${err instanceof Error ? err.message : 'Unknown error'}`
);
} finally {
setSavingLibraryRemote(false);
}
};
return (
<div className="min-h-screen flex flex-col items-center p-6">
{/* Add Provider Modal */}
@@ -515,7 +574,7 @@ export default function SettingsPage() {
<div>
<h2 className="text-sm font-medium text-white">Library</h2>
<p className="text-xs text-white/40">
Configuration library (server-managed)
Git-based configuration library for skills, tools, and agents
</p>
</div>
</div>
@@ -529,17 +588,59 @@ export default function SettingsPage() {
<Loader className="h-4 w-4 animate-spin text-white/40" />
<span className="text-sm text-white/40">Loading...</span>
</div>
) : health?.library_remote ? (
<div className="w-full rounded-lg border border-white/[0.06] bg-white/[0.01] px-3 py-2.5 text-sm text-white/70 font-mono">
{health.library_remote}
) : editingLibraryRemote ? (
<div className="space-y-2">
<input
type="text"
value={libraryRemoteValue}
onChange={(e) => setLibraryRemoteValue(e.target.value)}
placeholder="git@github.com:your-org/agent-library.git"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white font-mono focus:outline-none focus:border-indigo-500/50"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveLibraryRemote();
if (e.key === 'Escape') handleCancelEditLibraryRemote();
}}
/>
<div className="flex items-center gap-2">
<button
onClick={handleSaveLibraryRemote}
disabled={savingLibraryRemote}
className="flex items-center gap-1.5 rounded-lg bg-indigo-500 px-3 py-1.5 text-xs text-white hover:bg-indigo-600 transition-colors cursor-pointer disabled:opacity-50"
>
{savingLibraryRemote ? (
<Loader className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Save
</button>
<button
onClick={handleCancelEditLibraryRemote}
disabled={savingLibraryRemote}
className="flex items-center gap-1.5 rounded-lg border border-white/[0.06] px-3 py-1.5 text-xs text-white/60 hover:bg-white/[0.04] transition-colors cursor-pointer disabled:opacity-50"
>
<X className="h-3 w-3" />
Cancel
</button>
</div>
</div>
) : (
<div className="w-full rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-sm text-amber-400/80">
Not configured
<div
onClick={handleStartEditLibraryRemote}
className={cn(
'w-full rounded-lg border px-3 py-2.5 text-sm font-mono cursor-pointer transition-colors',
serverSettings?.library_remote
? 'border-white/[0.06] bg-white/[0.01] text-white/70 hover:border-indigo-500/30 hover:bg-white/[0.02]'
: 'border-amber-500/20 bg-amber-500/5 text-amber-400/80 hover:border-amber-500/30 hover:bg-amber-500/10'
)}
title="Click to edit"
>
{serverSettings?.library_remote || 'Not configured'}
</div>
)}
<p className="mt-1.5 text-xs text-white/30">
Set via <code className="text-white/50">LIBRARY_REMOTE</code> environment variable on the server.
Git remote URL for skills, tools, agents, and rules. Click to edit.
</p>
</div>
</div>

View File

@@ -1,6 +1,7 @@
'use client';
import { GitBranch, AlertTriangle, ExternalLink, Terminal } from 'lucide-react';
import Link from 'next/link';
import { GitBranch, AlertTriangle, ExternalLink, Settings } from 'lucide-react';
type LibraryUnavailableProps = {
message?: string | null;
@@ -22,22 +23,26 @@ export function LibraryUnavailable({ message }: LibraryUnavailableProps) {
<h2 className="text-lg font-semibold text-white mb-2">Library Not Configured</h2>
<p className="text-sm text-white/50 mb-6">
The configuration library is not set up on the server. Configure it to enable skills, commands, and templates.
The configuration library is not set up. Configure it to enable skills, commands, and templates.
</p>
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] p-5 text-left space-y-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 flex-shrink-0">
<Terminal className="h-4 w-4 text-indigo-400" />
<Settings className="h-4 w-4 text-indigo-400" />
</div>
<div>
<h3 className="text-sm font-medium text-white mb-1">Server Configuration</h3>
<h3 className="text-sm font-medium text-white mb-1">Configure Library Remote</h3>
<p className="text-xs text-white/40 mb-2">
Set the <code className="text-white/60 bg-white/[0.05] px-1 py-0.5 rounded">LIBRARY_REMOTE</code> environment variable on your server:
Set the library remote URL in Settings to connect to your configuration repository.
</p>
<div className="rounded-lg bg-black/30 border border-white/[0.06] px-3 py-2 font-mono text-xs text-white/70 overflow-x-auto">
LIBRARY_REMOTE=git@github.com:your/library.git
</div>
<Link
href="/settings"
className="inline-flex items-center gap-1.5 text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Go to Settings
<ExternalLink className="h-3 w-3" />
</Link>
</div>
</div>
@@ -63,10 +68,6 @@ export function LibraryUnavailable({ message }: LibraryUnavailableProps) {
</div>
</div>
<p className="mt-6 text-xs text-white/30">
After configuring, restart the Open Agent server for changes to take effect.
</p>
{showDetails && (
<p className="mt-4 text-[11px] text-white/20">Details: {details}</p>
)}

View File

@@ -2778,3 +2778,40 @@ export async function updateSystemComponent(
onError(e instanceof Error ? e.message : 'Unknown error');
}
}
// ============================================
// Global Settings API
// ============================================
export interface SettingsResponse {
library_remote: string | null;
}
export interface UpdateLibraryRemoteResponse {
library_remote: string | null;
library_reinitialized: boolean;
library_error?: string;
}
// Get all settings
export async function getSettings(): Promise<SettingsResponse> {
const res = await apiFetch('/api/settings');
if (!res.ok) throw new Error('Failed to get settings');
return res.json();
}
// Update the library remote URL
export async function updateLibraryRemote(
libraryRemote: string | null
): Promise<UpdateLibraryRemoteResponse> {
const res = await apiFetch('/api/settings/library-remote', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library_remote: libraryRemote }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update library remote');
}
return res.json();
}

View File

@@ -43,7 +43,7 @@ git remote set-url origin git@github.com:you/your-library.git
git push -u origin main
```
Then set `LIBRARY_REMOTE` in your Open Agent config to point to your repo.
Then configure your library in Open Agent's **Settings** page (preferred) or set `LIBRARY_REMOTE` as an environment variable.
## Skills

View File

@@ -142,7 +142,11 @@ async fn ensure_library(
state: &super::routes::AppState,
headers: &HeaderMap,
) -> Result<Arc<LibraryStore>, (StatusCode, String)> {
let remote = extract_library_remote(headers).or_else(|| state.config.library_remote.clone());
// Check HTTP header override first, then fall back to settings store
let remote = match extract_library_remote(headers) {
Some(r) => Some(r),
None => state.settings.get_library_remote().await,
};
let remote = remote.ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,

View File

@@ -31,6 +31,7 @@ pub mod opencode;
mod providers;
mod routes;
pub mod secrets;
pub mod settings;
pub mod system;
pub mod types;
pub mod workspaces;

View File

@@ -38,6 +38,7 @@ use super::mcp as mcp_api;
use super::monitoring;
use super::opencode as opencode_api;
use super::secrets as secrets_api;
use super::settings as settings_api;
use super::system as system_api;
use super::types::*;
use super::workspaces as workspaces_api;
@@ -69,6 +70,8 @@ pub struct AppState {
pub secrets: Option<Arc<crate::secrets::SecretsStore>>,
/// Console session pool for WebSocket reconnection
pub console_pool: Arc<console::SessionPool>,
/// Global settings store
pub settings: Arc<crate::settings::SettingsStore>,
}
/// Start the HTTP server.
@@ -130,6 +133,9 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
let console_pool = Arc::new(console::SessionPool::new());
Arc::clone(&console_pool).start_cleanup_task();
// Initialize global settings store
let settings = Arc::new(crate::settings::SettingsStore::new(&config.working_dir).await);
// Start background OpenCode session cleanup task
{
let opencode_base_url = std::env::var("OPENCODE_BASE_URL")
@@ -142,7 +148,9 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
// Initialize configuration library (optional - can also be configured at runtime)
// Must be created before ControlHub so it can be passed to control sessions
let library: library_api::SharedLibrary = Arc::new(RwLock::new(None));
if let Some(library_remote) = config.library_remote.clone() {
// Read library_remote from settings (which falls back to env var if not configured)
let library_remote = settings.get_library_remote().await;
if let Some(library_remote) = library_remote {
let library_clone = Arc::clone(&library);
let library_path = config.library_path.clone();
let workspaces_clone = Arc::clone(&workspaces);
@@ -220,6 +228,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
pending_oauth,
secrets,
console_pool,
settings,
});
// Start background desktop session cleanup task
@@ -388,6 +397,8 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
.nest("/api/ai/providers", ai_providers_api::routes())
// Secrets management endpoints
.nest("/api/secrets", secrets_api::routes())
// Global settings endpoints
.nest("/api/settings", settings_api::routes())
// Desktop session management endpoints
.nest("/api/desktop", desktop::routes())
// System component management endpoints
@@ -495,6 +506,8 @@ async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
AuthMode::SingleTenant => "single_tenant",
AuthMode::MultiUser => "multi_user",
};
// Read library_remote from settings store (persisted to disk)
let library_remote = state.settings.get_library_remote().await;
Json(HealthResponse {
status: "ok".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
@@ -502,7 +515,7 @@ async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
auth_required: state.config.auth.auth_required(state.config.dev_mode),
auth_mode: auth_mode.to_string(),
max_iterations: state.config.max_iterations,
library_remote: state.config.library_remote.clone(),
library_remote,
})
}

184
src/api/settings.rs Normal file
View File

@@ -0,0 +1,184 @@
//! API endpoints for global settings management.
use std::sync::Arc;
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, put},
Router,
};
use serde::{Deserialize, Serialize};
use crate::settings::Settings;
use crate::workspace;
use super::routes::AppState;
/// Create the settings API routes.
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(get_settings).put(update_settings))
.route("/library-remote", put(update_library_remote))
}
/// Response for settings endpoints.
#[derive(Debug, Serialize)]
pub struct SettingsResponse {
pub library_remote: Option<String>,
}
impl From<Settings> for SettingsResponse {
fn from(settings: Settings) -> Self {
Self {
library_remote: settings.library_remote,
}
}
}
/// Request to update all settings.
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest {
#[serde(default)]
pub library_remote: Option<String>,
}
/// Request to update library remote specifically.
#[derive(Debug, Deserialize)]
pub struct UpdateLibraryRemoteRequest {
/// Git remote URL. Set to null or empty string to clear.
pub library_remote: Option<String>,
}
/// Response after updating library remote.
#[derive(Debug, Serialize)]
pub struct UpdateLibraryRemoteResponse {
pub library_remote: Option<String>,
/// Whether the library was reinitialized.
pub library_reinitialized: bool,
/// Error message if library initialization failed.
#[serde(skip_serializing_if = "Option::is_none")]
pub library_error: Option<String>,
}
/// GET /api/settings
/// Get all settings.
async fn get_settings(State(state): State<Arc<AppState>>) -> Json<SettingsResponse> {
let settings = state.settings.get().await;
Json(settings.into())
}
/// PUT /api/settings
/// Update all settings.
async fn update_settings(
State(state): State<Arc<AppState>>,
Json(req): Json<UpdateSettingsRequest>,
) -> Result<Json<SettingsResponse>, (StatusCode, String)> {
let new_settings = Settings {
library_remote: req.library_remote,
};
state
.settings
.update(new_settings.clone())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(new_settings.into()))
}
/// PUT /api/settings/library-remote
/// Update the library remote URL and optionally reinitialize the library.
async fn update_library_remote(
State(state): State<Arc<AppState>>,
Json(req): Json<UpdateLibraryRemoteRequest>,
) -> Result<Json<UpdateLibraryRemoteResponse>, (StatusCode, String)> {
// Normalize empty string to None
let new_remote = req.library_remote.filter(|s| !s.trim().is_empty());
// Update the setting
let previous = state
.settings
.set_library_remote(new_remote.clone())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// If the value actually changed, reinitialize the library
let (library_reinitialized, library_error) = if previous.is_some() {
if let Some(ref remote) = new_remote {
// Reinitialize with new remote
match reinitialize_library(&state, remote).await {
Ok(()) => (true, None),
Err(e) => (false, Some(e)),
}
} else {
// Clear the library
*state.library.write().await = None;
tracing::info!("Library cleared (remote set to None)");
(true, None)
}
} else {
// No change in value
(false, None)
};
Ok(Json(UpdateLibraryRemoteResponse {
library_remote: new_remote,
library_reinitialized,
library_error,
}))
}
/// Reinitialize the library with a new remote URL.
async fn reinitialize_library(state: &Arc<AppState>, remote: &str) -> Result<(), String> {
let library_path = state.config.library_path.clone();
match crate::library::LibraryStore::new(library_path, remote).await {
Ok(store) => {
// Sync OpenCode plugins
if let Ok(plugins) = store.get_plugins().await {
if let Err(e) = crate::opencode_config::sync_global_plugins(&plugins).await {
tracing::warn!("Failed to sync OpenCode plugins: {}", e);
}
}
tracing::info!("Configuration library reinitialized from {}", remote);
let library = Arc::new(store);
*state.library.write().await = Some(Arc::clone(&library));
// Sync skills/tools to all workspaces
let workspaces = state.workspaces.list().await;
for ws in workspaces {
let is_default_host = ws.id == workspace::DEFAULT_WORKSPACE_ID
&& ws.workspace_type == workspace::WorkspaceType::Host;
if is_default_host || !ws.skills.is_empty() {
if let Err(e) = workspace::sync_workspace_skills(&ws, &library).await {
tracing::warn!(
workspace = %ws.name,
error = %e,
"Failed to sync skills after library reinit"
);
}
}
if is_default_host || !ws.tools.is_empty() {
if let Err(e) = workspace::sync_workspace_tools(&ws, &library).await {
tracing::warn!(
workspace = %ws.name,
error = %e,
"Failed to sync tools after library reinit"
);
}
}
}
Ok(())
}
Err(e) => {
tracing::error!("Failed to reinitialize library from {}: {}", remote, e);
Err(e.to_string())
}
}
}

View File

@@ -13,6 +13,8 @@
//! - `LIBRARY_GIT_SSH_KEY` - Optional. SSH key path for library git operations. If set to a path, uses that key.
//! If set to empty string, ignores ~/.ssh/config (useful when the config specifies a non-existent key).
//! If unset, uses default SSH behavior.
//! - `LIBRARY_REMOTE` - Optional. Initial library remote URL (can be changed via Settings in the dashboard).
//! This environment variable is used as the initial default when no settings file exists.
//!
//! Note: The agent has **full system access**. It can read/write any file, execute any command,
//! and search anywhere on the machine. The `WORKING_DIR` is just the default for relative paths.
@@ -221,10 +223,6 @@ pub struct Config {
/// Path to the configuration library git repo.
/// Default: {working_dir}/.openagent/library
pub library_path: PathBuf,
/// Git remote URL for the configuration library.
/// Set via LIBRARY_REMOTE env var. Runtime settings can override this.
pub library_remote: Option<String>,
}
/// API auth configuration.
@@ -446,12 +444,11 @@ impl Config {
let context = ContextConfig::from_env();
// Library configuration
// Note: library_remote is now managed via the settings module (persisted to disk)
let library_path = std::env::var("LIBRARY_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| working_dir.join(".openagent/library"));
let library_remote = std::env::var("LIBRARY_REMOTE").ok();
Ok(Self {
default_model,
working_dir,
@@ -467,7 +464,6 @@ impl Config {
opencode_agent,
opencode_permissive,
library_path,
library_remote,
})
}
@@ -489,7 +485,6 @@ impl Config {
opencode_agent: None,
opencode_permissive: true,
library_path,
library_remote: None,
}
}
}

View File

@@ -43,6 +43,7 @@ pub mod nspawn;
pub mod opencode;
pub mod opencode_config;
pub mod secrets;
pub mod settings;
pub mod task;
pub mod tools;
pub mod workspace;
@@ -50,3 +51,4 @@ pub mod workspace;
pub use ai_providers::{AIProvider, AIProviderStore, ProviderType};
pub use config::Config;
pub use opencode_config::{OpenCodeConnection, OpenCodeStore};
pub use settings::{Settings, SettingsStore};

134
src/settings.rs Normal file
View File

@@ -0,0 +1,134 @@
//! Global settings storage.
//!
//! Persists user-configurable settings to disk at `{working_dir}/.openagent/settings.json`.
//! Environment variables are used as initial defaults when no settings file exists.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Global application settings.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Settings {
/// Git remote URL for the configuration library.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub library_remote: Option<String>,
}
/// In-memory store for global settings with disk persistence.
#[derive(Debug)]
pub struct SettingsStore {
settings: RwLock<Settings>,
storage_path: PathBuf,
}
impl SettingsStore {
/// Create a new settings store, loading from disk if available.
///
/// If no settings file exists, uses environment variables as defaults:
/// - `LIBRARY_REMOTE` - Git remote URL for the configuration library
pub async fn new(working_dir: &PathBuf) -> Self {
let storage_path = working_dir.join(".openagent/settings.json");
let settings = if storage_path.exists() {
match Self::load_from_path(&storage_path) {
Ok(s) => {
tracing::info!("Loaded settings from {}", storage_path.display());
s
}
Err(e) => {
tracing::warn!(
"Failed to load settings from {}: {}, using defaults",
storage_path.display(),
e
);
Self::defaults_from_env()
}
}
} else {
tracing::info!(
"No settings file found at {}, using environment defaults",
storage_path.display()
);
Self::defaults_from_env()
};
Self {
settings: RwLock::new(settings),
storage_path,
}
}
/// Load settings from environment variables as initial defaults.
fn defaults_from_env() -> Settings {
Settings {
library_remote: std::env::var("LIBRARY_REMOTE").ok(),
}
}
/// Load settings from a file path.
fn load_from_path(path: &PathBuf) -> Result<Settings, std::io::Error> {
let contents = std::fs::read_to_string(path)?;
serde_json::from_str(&contents)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
/// Save current settings to disk.
async fn save_to_disk(&self) -> Result<(), std::io::Error> {
let settings = self.settings.read().await;
// Ensure parent directory exists
if let Some(parent) = self.storage_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(&*settings)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&self.storage_path, contents)?;
tracing::debug!("Saved settings to {}", self.storage_path.display());
Ok(())
}
/// Get a clone of the current settings.
pub async fn get(&self) -> Settings {
self.settings.read().await.clone()
}
/// Get the library remote URL.
pub async fn get_library_remote(&self) -> Option<String> {
self.settings.read().await.library_remote.clone()
}
/// Update the library remote URL.
///
/// Returns the previous value if it changed, or None if unchanged.
pub async fn set_library_remote(
&self,
remote: Option<String>,
) -> Result<Option<String>, std::io::Error> {
let mut settings = self.settings.write().await;
let previous = settings.library_remote.clone();
if previous != remote {
settings.library_remote = remote;
drop(settings); // Release lock before saving
self.save_to_disk().await?;
Ok(previous)
} else {
Ok(None) // No change
}
}
/// Update multiple settings at once.
pub async fn update(&self, new_settings: Settings) -> Result<(), std::io::Error> {
let mut settings = self.settings.write().await;
*settings = new_settings;
drop(settings);
self.save_to_disk().await
}
}
/// Shared settings store wrapped in Arc for concurrent access.
pub type SharedSettingsStore = Arc<SettingsStore>;