using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LlamaApp;
///
/// User-configured application settings, persisted as JSON in the app's
/// per-user local data folder. Holds the Hugging Face access token (for
/// authenticated downloads / private repos) and the local models cache
/// directory (where GGUF files live, shared with the HF cache layout).
///
public sealed class Settings
{
// Declared BEFORE Current so its static field initializer runs first.
// Static field initializers run in textual order, and Current's
// initializer calls — if SettingsPath were declared
// below it, Load would see SettingsPath == null (still its default),
// would return false, and every saved setting
// (HuggingFace token, cache directory, startup hint) would be silently
// discarded on every launch. Keep this above any member that calls Load.
private static readonly string SettingsPath = Path.Combine(
Common.AppData.Root, "settings.json");
/// Singleton instance; loaded lazily on first access and cached.
public static Settings Current { get; } = Load();
static Settings()
{
// Make sure the directory exists so Save() never throws on a missing dir.
try { Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!); }
catch { /* best-effort */ }
}
///
/// Hugging Face access token (hf_…). Optional — only needed for downloading
/// private/gated repos. Never serialized: writes it to
/// through DPAPI (bound to this
/// user on this machine), so the settings file carries no working
/// credential if it is copied or stolen. Leave empty for anonymous access
/// to public repos.
///
[JsonIgnore]
public string HuggingFaceToken { get; set; } = "";
///
/// DPAPI-protected form of — the only
/// representation ever written to settings.json. Written by
/// ; restored (best-effort, empty on any failure) by
/// . Not for direct use.
///
[JsonPropertyName("ProtectedHuggingFaceToken")]
public string ProtectedHuggingFaceToken { get; set; } = "";
///
/// Local directory where downloaded GGUF models are cached. Defaults to the
/// standard Hugging Face cache (%USERPROFILE%\.cache\huggingface\hub)
/// so models are shared with llama.cpp and other HF-aware tools.
///
public string CacheDirectory { get; set; } =
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cache", "huggingface", "hub");
///
/// Port the local llama server listens on (default 9931). Read once at
/// startup when the singleton is created
/// (App.OnLaunched), so a changed value takes effect on the next app
/// launch. Valid range: 1–65535; out-of-range values fall back to the
/// default at startup.
///
public int ServerPort { get; set; } = Llama.LlamaManager.DefaultServerPort;
///
/// IPv4 address the llama server binds to (--host): the loopback
/// 127.0.0.1 (the default, reachable only from this machine),
/// 0.0.0.0 for every interface, or a specific interface's address
/// from Settings → Llama. Read once at startup when the
/// singleton is created, so a changed
/// value takes effect on the next app launch — the app's own REST client
/// follows the same address (loopback when the server binds all
/// interfaces).
///
public string ListenAddress { get; set; } = LlamaApp.Common.ListenAddresses.Localhost;
///
/// Key the app generates and hands to the llama server via the
/// LLAMA_API_KEY environment variable
/// when is not loopback, so a network-exposed
/// server isn't an open control API. Reused across restarts (and across the
/// switch back to a non-loopback bind) so an adopted server stays
/// reachable. Empty for the default loopback bind, which needs no key.
///
/// Like , never serialized:
/// writes it to
/// through DPAPI (bound to this user on this machine), so a copied or
/// stolen settings file carries no working credential. The server reads a
/// materialized copy at launch (see LlamaManager.StartServerAsync);
/// the key is a LAN access token for the model server — not a user
/// identity credential — but it belongs behind the same door as the HF
/// token.
///
[JsonIgnore]
public string ServerApiKey { get; set; } = "";
///
/// DPAPI-protected form of — the only
/// representation ever written to settings.json. Written by
/// ; restored (best-effort, empty on any failure) by
/// . Not for direct use.
///
[JsonPropertyName("ProtectedServerApiKey")]
public string ProtectedServerApiKey { get; set; } = "";
///
/// Seconds of idleness after which the llama server unloads the model from
/// memory: 300 (5 min), 900 (15 min), 3600 (1 hour), or -1 (never, the
/// default). Handed to the server as --sleep-idle-seconds at launch
/// (see ), so a changed
/// value takes effect on the next server start. An idled-out model stays
/// listed and wakes transparently on the next request.
///
public int IdleUnloadSeconds { get; set; } = -1;
///
/// Maximum number of models the llama server loads simultaneously in
/// router mode: 0 (the default) means unlimited, any positive value caps
/// how many per-model child servers run at once. Handed to the server as
/// --models-max at launch (see
/// ), so a changed value
/// takes effect on the next server start.
///
public int MaxLoadedModels { get; set; } = 0;
///
/// KV cache quantization type for the key tensor, handed to the server as
/// --cache-type-k at launch. One of f32, f16,
/// bf16, q8_0, q4_0, q4_1, iq4_nl,
/// q5_0, or q5_1. Defaults to f16, llama.cpp's own
/// default; lower-precision types shrink the KV cache so a larger context
/// fits in GPU memory. A changed value takes effect on the next server
/// start.
///
public string CacheTypeK { get; set; } = "f16";
///
/// KV cache quantization type for the value tensor, handed to the server
/// as --cache-type-v at launch. Same value set and default as
/// . K and V can be quantized independently.
///
public string CacheTypeV { get; set; } = "f16";
///
/// Free-form extra llama serve arguments, appended after the
/// built-in flags so they can override them (llama.cpp honors the last
/// occurrence). Stored exactly as typed — including newlines, which the
/// tokenizer treats as whitespace — and split into argv tokens at launch
/// (see ). Applies the
/// next time the llama server starts; a bad flag makes the server exit
/// with an error, which the app surfaces as the failure reason.
///
public string CustomServeArguments { get; set; } = "";
///
/// Whether Llama should launch automatically when the user signs in to
/// Windows. The authoritative state is the presence of the startup
/// shortcut managed by (in the user's Startup
/// folder); this value is a persisted hint so the Settings checkbox can
/// reflect intent on first open before re-reading the OS state.
///
public bool LaunchAtStartup { get; set; } = false;
///
/// Whether the one-time first-run hint ("Llama lives in the system
/// tray; Alt+Space opens the chat overlay") has been shown. Persisted so
/// the toast fires exactly once, on the first launch.
///
public bool TrayHintShown { get; set; } = false;
///
/// Per-model context-length preferences chosen in the model details view,
/// keyed by the server model id (repo:quant) — context length is a
/// per-model choice, not a global one (a 1B model and a 70B model want very
/// different defaults). Applied as ctx_size on the next
/// /models/load (see ).
///
public Dictionary ModelContextLengths { get; set; } = new();
///
/// Per-model speculative-decoding draft heads, keyed by the server model
/// id (repo:quant): the value is the llama.cpp --spec-type
/// token (draft-dflash, draft-dspark, draft-mtp), or
/// an empty string for "checked, the repo ships none" (a negative cache
/// so the Hub isn't re-queried on every session). Detected from the
/// repo's file listing (see ) when the
/// model is downloaded and for cached models at startup; rendered into
/// the --models-preset INI so the server downloads and wires the
/// sidecar at the model's next load. Models that ship no head carry no
/// spec decoding — the setting is not user-editable.
///
public Dictionary ModelSpeculativeTypes { get; set; } = new();
///
/// Folders enrolled for model discovery (Settings → General → Model
/// folders): places the user already keeps GGUF models — another drive,
/// a private stash — scanned recursively and surfaced as loadable models
/// without copying or moving anything (each discovery becomes a
/// model = path section in the llama server's --models-preset
/// INI). Kept strictly separate from — that
/// stays where llama downloads its models, and cache contents are never
/// scanned from here (enrolling %LOCALAPPDATA% must not duplicate the
/// cache). Paths as given by the folder picker; missing folders are
/// skipped at scan time. Takes effect at the next server start, and
/// immediately (preset reload) when changed while the server runs.
///
public List ModelFolders { get; set; } = [];
private static Settings Load()
{
try
{
if (File.Exists(SettingsPath))
{
var json = File.ReadAllText(SettingsPath);
// One parse: deserialization and the legacy-secret migration
// read from the same JsonDocument.
using var doc = JsonDocument.Parse(json);
var s = doc.RootElement.Deserialize(SettingsJsonContext.Default.Settings);
if (s != null)
{
RestoreSecrets(s, doc.RootElement);
return s;
}
}
}
catch (Exception ex)
{
// Corrupt or unreadable settings — fall back to defaults rather than
// crashing the app. The user can re-enter values in the Settings UI.
Common.Log.Warn(ex, "settings load failed; using defaults");
}
return new Settings();
}
///
/// Moves the token from the raw JSON into :
/// the DPAPI blob wins; a file written before the token was protected
/// still carries the plaintext field and is migrated (and will be
/// rewritten protected on the next ). Best-effort — a
/// blob that can't be decrypted (another machine/user, corruption) reads
/// back as empty and the token is simply unset. Internal for tests.
///
internal static void RestoreSecrets(Settings s, string json)
{
try
{
using var doc = JsonDocument.Parse(json);
RestoreSecrets(s, doc.RootElement);
}
catch (JsonException)
{
// Handled by Load's caller path — keep whatever deserialized.
}
}
///
/// against an already
/// parsed document, so the settings load parses settings.json once.
///
internal static void RestoreSecrets(Settings s, JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object) return;
if (root.TryGetProperty("HuggingFaceToken", out var legacy) &&
legacy.ValueKind == JsonValueKind.String)
{
var plaintext = legacy.GetString() ?? "";
if (plaintext.Length > 0 && string.IsNullOrWhiteSpace(s.ProtectedHuggingFaceToken))
s.HuggingFaceToken = plaintext;
}
if (root.TryGetProperty("ProtectedHuggingFaceToken", out var blob) &&
blob.ValueKind == JsonValueKind.String)
{
var plaintext = Common.SecretProtector.Unprotect(blob.GetString());
if (plaintext.Length > 0)
s.HuggingFaceToken = plaintext;
}
// The server API key: same legacy-plaintext-then-blob order as the
// token above. The plaintext "ServerApiKey" property predates
// DPAPI protection; it is adopted only when no protected form
// exists, and disappears from the file on the next Save (the
// property is [JsonIgnore]).
if (root.TryGetProperty("ServerApiKey", out var legacyKey) &&
legacyKey.ValueKind == JsonValueKind.String)
{
var plaintext = legacyKey.GetString() ?? "";
if (plaintext.Length > 0 && string.IsNullOrWhiteSpace(s.ProtectedServerApiKey))
s.ServerApiKey = plaintext;
}
if (root.TryGetProperty("ProtectedServerApiKey", out var keyBlob) &&
keyBlob.ValueKind == JsonValueKind.String)
{
var plaintext = Common.SecretProtector.Unprotect(keyBlob.GetString());
if (plaintext.Length > 0)
s.ServerApiKey = plaintext;
}
}
///
/// Persists the current values to settings.json with the Hugging
/// Face token and the server API key DPAPI-protected. Best-effort: a
/// failure (e.g. disk full) is swallowed and returns false rather than
/// surfacing in the UI flow.
///
public bool Save()
{
try
{
ProtectedHuggingFaceToken = Common.SecretProtector.Protect(HuggingFaceToken);
ProtectedServerApiKey = Common.SecretProtector.Protect(ServerApiKey);
// The three collections below are shared BY REFERENCE with
// background readers/writers (LlamaManager's INI renderer and
// folder scan, the draft-head poller — see
// DraftHeadDetection.Apply's lock note), and the serializer
// traverses them. Save takes the same per-collection locks those
// sites use, in declaration order (no lock holder ever acquires
// another, so nesting here is deadlock-free). Without this, a
// mutation racing the serialization failed the whole save to a
// caught "collection was modified".
string json;
lock (ModelContextLengths)
lock (ModelSpeculativeTypes)
lock (ModelFolders)
json = JsonSerializer.Serialize(this, SettingsJsonContext.Default.Settings);
File.WriteAllText(SettingsPath, json);
return true;
}
catch (Exception ex)
{
Common.Log.Warn(ex, "settings save failed");
return false;
}
}
}