namespace LlamaApp
{
///
/// Provides application-specific behavior to supplement the default Application class.
///
public partial class App : Application
{
private MainWindow? _window;
private TrayIconManager? _trayIcon;
private OverlayWindow? _overlay;
private GlobalHotkey? _hotkey;
private Llama.RuntimeUpdateScheduler? _runtimeUpdates;
///
/// In-flight llama://install downloads by server model id
/// (repo:quant), for the install progress toast's Cancel
/// button — these downloads have no MainWindow row to own a
/// cancellation source until the poller adds one.
///
private static readonly System.Collections.Concurrent.ConcurrentDictionary
InstallDownloads = new();
///
/// At most one deep-link install download at a time — N rapid links
/// must not start N parallel multi-GB downloads.
///
private static readonly System.Threading.SemaphoreSlim InstallGate = new(1, 1);
///
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
///
public App()
{
// Move the pre-rename data folder (%LOCALAPPDATA%\LlamaApp) to its
// new home (%LOCALAPPDATA%\Llama) before logging or settings touch it.
Common.AppData.MigrateLegacyFolder();
// Initialize the global logger first, before anything that can
// fail: a debug build logs at Debug level, a release build at Info.
// The log lives at %LOCALAPPDATA%\Llama\logs\Llama-YYYYMMDD.log
// (rolled daily, old files swept to a 7-day retention).
Common.Log.Initialize(level:
#if DEBUG
Common.LogLevel.Debug
#else
Common.LogLevel.Info
#endif
);
Common.Log.Info("Llama starting");
_dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()
?? throw new InvalidOperationException("App must initialize on the UI thread.");
// XAML-domain unhandled exceptions (including failures inside the
// async void OnLaunched continuation) are otherwise SWALLOWED by
// the framework: the process keeps pumping with no windows, tray
// icon or hotkey — the app silently "doesn't start". Surface every
// one in the log so the failure is diagnosable.
UnhandledException += (_, e) =>
{
Common.Log.Error(e.Exception, "unhandled XAML exception");
};
InitializeComponent();
}
private Microsoft.UI.Dispatching.DispatcherQueue _dispatcher = null!;
///
/// Invoked when the end user launches the application normally. Other entry points
/// will be used, such as when the application is launched to open a specific file.
///
/// Details about the launch request and process.
protected override async void OnLaunched(LaunchActivatedEventArgs e)
{
// Single-instance: a second launch redirects its activation to the
// already-running instance (which re-opens the flyout — see the
// Activated handler below) and exits, instead of creating a
// duplicate tray icon and a second /models poller fighting over
// the same server port.
var instance = Microsoft.Windows.AppLifecycle.AppInstance
.FindOrRegisterForKey("Llama");
if (!instance.IsCurrent)
{
Common.Log.Info("another instance is already running; redirecting activation and exiting");
try
{
// The running instance must learn about this launch. Its
// AppInstance.Activated callback never fires on this setup
// (verified), so the request travels via the handoff file +
// named event instead: an "Open With" .gguf, a Windows-share
// payload, or nothing at all (the plain "reopen the flyout"
// second launch). A share's DataPackageView cannot cross a
// process boundary, so the payload is extracted HERE — in the
// process that received it — and only the distilled request
// (a path, or the text) is handed off.
var activatedArgs = instance.GetActivatedEventArgs();
var handoff = await ExtractHandoffFromActivationAsync(activatedArgs);
switch (handoff.Kind)
{
case Common.HandoffKind.ModelPath:
Common.InstanceHandoff.WriteModel(handoff.ModelPath!);
break;
case Common.HandoffKind.Text:
Common.InstanceHandoff.WriteText(handoff.Text!);
break;
case Common.HandoffKind.Image:
Common.InstanceHandoff.WriteImage(handoff.ModelPath!);
break;
case Common.HandoffKind.ModelId:
Common.InstanceHandoff.WriteModelId(handoff.ModelId!);
break;
case Common.HandoffKind.Install:
Common.InstanceHandoff.WriteInstall(handoff.Repo!);
break;
}
Common.InstanceHandoff.Signal();
// Donate this process's foreground right to the running
// instance: the shell launched US from the user's click,
// so we may set the foreground window — but the running
// instance receives no activation for a protocol/share
// handoff (the redirect below is skipped for them), and
// without a foreground right its SetForegroundWindow is
// denied: the flyout it shows for this request stays
// hidden behind the caller's window.
AllowSetForegroundToRunningInstance();
// Redirect the activation to the running instance — the
// documented pattern for launch/file activations — EXCEPT
// for shares and protocol (deep-link) activations:
//
// - RedirectActivationToAsync does not support ShareTarget
// activations (the ShareOperation cannot cross processes,
// which is exactly why the payload travels via the handoff
// above). Redirecting a share anyway leaves the OS share
// machinery with an activation it cannot deliver, and in
// practice this failed fast the second process
// (0xC0000409 from the native share host).
// - A redirected PROTOCOL activation is delivered into the
// running instance over RPC — and that delivery raises a
// native exception inside the Windows App Runtime's
// activation-marshalling code which fail-fast KILLS the
// running instance (0xC0000409 /
// FAST_FAIL_FATAL_APP_EXIT in ucrtbase — verified from
// the crash dump: the faulting thread is an RPC/COM
// thread whose stack is all Microsoft.WindowsAppRuntime
// frames, with no managed frames to catch it). The deep
// link's full payload already travels via the handoff
// file + named event above, so the redirect is redundant
// for it — and it is the crash vector.
var isShareActivation =
activatedArgs.Data is Windows.ApplicationModel.Activation.ShareTargetActivatedEventArgs;
var isProtocolActivation =
activatedArgs.Kind == Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol;
if (!isShareActivation && !isProtocolActivation)
await Task.Run(() => instance.RedirectActivationToAsync(activatedArgs));
}
catch (Exception ex)
{
Common.Log.Warn(ex, "activation redirect failed");
}
Environment.Exit(0);
return;
}
// The startup shortcut was renamed with the app ("LlamaApp.lnk" →
// "Llama.lnk") — sweep any leftover from a pre-rename install.
StartupHelper.RemoveLegacyShortcut();
// "Open With → Llama" on a .gguf and Windows shares both deliver
// through this instance's activation arguments (OnLaunched is the
// one startup hook WinUI 3 desktop has — there is no
// OnFileActivated/OnShareTargetActivated); an unpackaged
// `LlamaApp.exe ` launch delivers it on the command
// line (LaunchActivatedEventArgs.Arguments is empty for
// unpackaged launches — verified). Captured here, handled once
// startup completes below. A cold-start share is ours to report
// (the share sheet is waiting on this process).
var activationArgs = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().GetActivatedEventArgs();
var startupHandoff = await ExtractHandoffFromActivationAsync(activationArgs);
var openModelFile = startupHandoff.Kind == Common.HandoffKind.ModelPath ? startupHandoff.ModelPath : null;
var sharedPrompt = startupHandoff.Kind == Common.HandoffKind.Text ? startupHandoff.Text : null;
var openModelId = startupHandoff.Kind == Common.HandoffKind.ModelId ? startupHandoff.ModelId : null;
var installRepo = startupHandoff.Kind == Common.HandoffKind.Install ? startupHandoff.Repo : null;
openModelFile ??= FirstGgufOnCommandLine();
// Create the llama-server manager singleton with the configured
// port and listen address BEFORE anything else can touch
// LlamaManager.Shared — the MainWindow constructor below already
// subscribes to its events. Both are baked into the manager at
// construction, so values changed in Settings only take effect on
// the next launch.
var serverPort = Settings.Current.ServerPort;
if (serverPort is < 1 or > 65535)
{
Common.Log.Warn($"configured server port {serverPort} is out of range; falling back to {Llama.LlamaManager.DefaultServerPort}");
serverPort = Llama.LlamaManager.DefaultServerPort;
}
// The saved listen address may no longer be assigned to this
// machine (DHCP change, a different network, or a hand-edited
// settings.json). Resolve it against the live interfaces so the
// app never targets — or adopts a server from — a foreign host;
// fall back to loopback and warn when it can't be used.
var resolvedListen = Common.ListenAddresses.Resolve(
Settings.Current.ListenAddress, Common.ListenAddresses.List());
if (resolvedListen.FellBack)
Common.Log.Warn(
$"listen address '{Settings.Current.ListenAddress}' is not assigned to this machine; using {resolvedListen.Address}");
// Non-loopback binds get an app-generated API key so the server
// isn't an open control API on the network. Generated once and
// persisted, so an adopted server (and a later app start) agree.
string? apiKey = null;
if (Common.ServerAuth.RequiresApiKey(resolvedListen.Address))
{
if (string.IsNullOrWhiteSpace(Settings.Current.ServerApiKey))
{
Settings.Current.ServerApiKey = Common.ServerAuth.GenerateApiKey();
Settings.Current.Save();
}
apiKey = Settings.Current.ServerApiKey;
}
Common.Log.Info(
$"listen address: {resolvedListen.Address}{(apiKey is null ? "" : " (API key required)")}");
Llama.LlamaManager.Initialize(serverPort, resolvedListen.Address, apiKey);
// The app is tray-only: the main window is created but never shown
// on launch. It is revealed on demand as a flyout anchored to the
// system-tray icon (see TrayIconManager / MainWindow.ShowAsFlyout),
// mirroring the macOS menu-bar app on Windows 11.
_window = new MainWindow();
// The footer's Quit button asks the tray manager to tear the app
// down (dispose the icon, close the window, exit).
_window.ExitRequested += () => _trayIcon?.RequestExit();
// Add the system-tray icon with its right-click context menu.
_trayIcon = new TrayIconManager(_window);
// Listen for a second instance's signal (Open With handoff, a
// Windows share, or a plain second launch): the named event fires,
// we consume any pending request and act on it. Replaces the
// AppInstance.Activated path that never fires on this setup.
var openWithEvent = Common.InstanceHandoff.Listen();
ThreadPool.RegisterWaitForSingleObject(
openWithEvent,
(_, _) => _dispatcher.TryEnqueue(HandleSecondLaunchSignal),
null, Timeout.Infinite, executeOnlyOnce: false);
// Toast notifications for background events (model loaded, download
// failed) while the flyout is hidden. Clicking a toast re-opens the
// flyout; so does a redirected second-launch activation.
Notifications.Initialize();
// Surface a listen-address fallback once notifications exist.
if (resolvedListen.FellBack)
Notifications.Show("Listen address unavailable",
$"The configured listen address ({Settings.Current.ListenAddress}) isn't assigned to this machine. " +
$"The llama server will listen on {resolvedListen.Address} instead.");
Notifications.Invoked += args => _dispatcher.TryEnqueue(() => HandleToastActivation(args));
instance.Activated += (sender, args) => _dispatcher.TryEnqueue(() =>
{
// Belt-and-braces: when the redirect DOES deliver (MSIX on
// other configurations), handle the activation directly.
var request = FirstGgufFromFileActivation(args.Data) is { } directModel
? Common.HandoffRequest.ForModel(directModel)
: Common.InstanceHandoff.TryConsume();
DispatchHandoff(request);
});
// Ensure a llama.cpp server is reachable on the configured port:
// adopt an already-running one, launch one from a found binary, or
// download the binary via install.ps1 then launch. Fire-and-forget;
// once reachable the MainWindow fetches the Available model list via
// GET /models.
Llama.LlamaManager.Shared.CacheDirectory = Settings.Current.CacheDirectory;
Llama.LlamaManager.Shared.HuggingFaceToken = Settings.Current.HuggingFaceToken;
Llama.LlamaManager.Shared.IdleUnloadSeconds = Settings.Current.IdleUnloadSeconds;
Llama.LlamaManager.Shared.MaxLoadedModels = Settings.Current.MaxLoadedModels;
Llama.LlamaManager.Shared.CacheTypeK = Settings.Current.CacheTypeK;
Llama.LlamaManager.Shared.CacheTypeV = Settings.Current.CacheTypeV;
Llama.LlamaManager.Shared.CustomServeArguments = Settings.Current.CustomServeArguments;
// Shared by reference: the details view mutates this dictionary when
// the user picks a context length, and the manager renders it into
// the server's --models-preset INI (see ReloadModelPresetsAsync).
Llama.LlamaManager.Shared.ModelContextLengths = Settings.Current.ModelContextLengths;
// Draft-head detection shares the same INI channel: spec-type
// entries (draft-dflash/…) detected from Hub repo listings ride
// beside the context lengths (see DraftHeadDetection).
Llama.LlamaManager.Shared.ModelSpeculativeTypes = Settings.Current.ModelSpeculativeTypes;
// Enrolled model folders share the same INI channel: the recursive
// scan (Settings → General → Model folders) renders a
// model = path section per discovery.
Llama.LlamaManager.Shared.ModelFolders = Settings.Current.ModelFolders;
Common.Log.Info($"cache directory: {Settings.Current.CacheDirectory}");
// Presence only — never log the token itself.
Common.Log.Info($"HF token: {(string.IsNullOrWhiteSpace(Settings.Current.HuggingFaceToken) ? "not set" : "configured")}");
// Weekly runtime self-update: when the last check (persisted under
// %LOCALAPPDATA%\Llama) is a week old or older, check llama.cpp's
// latest release and install it via install.ps1 BEFORE the server
// launches — the one point where llama.exe is not in use. The
// scheduler then runs the ensure and keeps an hourly watch; it
// defers installs while the server is up. Fire-and-forget like
// the ensure it wraps.
_runtimeUpdates = new Llama.RuntimeUpdateScheduler(Llama.LlamaManager.Shared);
// The weekly pass is silent — but a successful install deserves a
// toast (it explains why the server takes a moment longer the
// next time it starts, and why the version footer changed).
_runtimeUpdates.RuntimeUpdated += build => _dispatcher.TryEnqueue(() =>
Notifications.Show("llama.cpp updated",
$"The llama.cpp runtime was updated to b{build}. The new version is used the next time the server starts."));
_ = _runtimeUpdates.StartAsync(
() => Llama.LlamaManager.Shared.EnsureLlamaOrDownloadAsync());
// Spotlight-style prompt overlay, summoned by a global Alt+Space
// hotkey. Created lazily on first press and reused thereafter; the
// hotkey is registered for the app's lifetime only, so it stops
// working the moment Llama exits.
_overlay = new OverlayWindow();
_hotkey = new GlobalHotkey();
// Initialize the overlay's WebView2 NOW — at a calm point in
// startup, awaited and fully observed — rather than at the first
// summon. The app runs x64-emulated on ARM64, and the first WebView2
// initialization occasionally trips the emulation layer when a
// share delivery triggers the first summon while the OS share
// machinery is churning around the process (unobserved WinRT
// failure → stowed exception → 0xC000027B fail-fast, catchable by
// nothing). Prewarmed, the first summon just navigates — and is
// instant, too.
_overlay.PrewarmWebView();
_hotkey.Register(() => _dispatcher.TryEnqueue(() => _overlay.Summon()));
// The hotkey is hard-coded and otherwise undiscoverable — say so
// when it can't work instead of the shortcut silently doing
// nothing (RegisterHotKey fails when another app owns Alt+Space).
if (!_hotkey.IsRegistered)
{
Notifications.Show("Alt+Space unavailable",
"Another app is using the Alt+Space shortcut, so the chat overlay hotkey is off.");
}
// One-time first-run hint: the app is tray-only (no window appears
// on launch) and the overlay hotkey is undiscoverable — tell the
// user both, exactly once.
if (!Settings.Current.TrayHintShown)
{
Settings.Current.TrayHintShown = true;
Settings.Current.Save();
Notifications.Show("Llama is running",
"Find it in the system tray — and press Alt+Space anytime to chat with a loaded model.");
}
Common.Log.Info("startup complete");
// The .gguf this instance was opened for, or the text it received
// as a share (see startupHandoff above) — now the tray, server
// pipeline and overlay all exist. A handoff left by a second
// instance that signalled before the listener was up is consumed
// here the same way.
var startupRequest = openModelFile is not null
? Common.HandoffRequest.ForModel(openModelFile)
: (sharedPrompt is not null
? Common.HandoffRequest.ForText(sharedPrompt)
: (openModelId is not null
? Common.HandoffRequest.ForModelId(openModelId)
: (installRepo is not null
? Common.HandoffRequest.ForInstall(installRepo)
: Common.InstanceHandoff.TryConsume())));
DispatchHandoff(startupRequest);
// Speculative-decoding draft heads (issue #97): watch the server's
// model list — any model that appears unchecked (downloaded from
// the app, from the WebUI in a browser, cached before the feature,
// or adopted with the server) is detected within a poll cycle.
// Fire-and-forget: the watcher owns its own threading.
DraftHeadDetection.StartWatching();
// Enrolled model folders stay live the same way: a .gguf added or
// removed on disk refreshes the server's view without a restart
// (fire-and-forget; the watcher owns its own threading).
EnrolledFolderWatch.StartWatching();
}
///
/// A second instance signalled (named event): consume whatever request
/// it handed off — a model file ("Open With"/share) loads, shared
/// text goes to the chat overlay; a plain second launch re-opens the
/// flyout, as it always should have.
///
private void HandleSecondLaunchSignal()
{
DispatchHandoff(Common.InstanceHandoff.TryConsume());
}
///
/// Acts on one handoff request: a model file loads through the
/// "Open With" pipeline, shared text is delivered to the chat
/// overlay's composer, and no request re-opens the flyout.
///
private void DispatchHandoff(Common.HandoffRequest request)
{
switch (request.Kind)
{
case Common.HandoffKind.ModelPath:
_ = OpenModelFileAsync(request.ModelPath!);
break;
case Common.HandoffKind.Text:
_ = HandleSharedPromptAsync(request.Text!);
break;
case Common.HandoffKind.Image:
_ = HandleSharedImageAsync(request.ModelPath!);
break;
case Common.HandoffKind.ModelId:
_ = LoadModelByIdAsync(request.ModelId!);
break;
case Common.HandoffKind.Install:
_ = InstallRepoByIdAsync(request.Repo!);
break;
default:
_trayIcon?.ShowFlyout();
break;
}
}
///
/// Lets the running instance take the foreground (for the flyout it
/// shows on this handoff). The shell gave the foreground right to
/// THIS short-lived process; hand it to the instance that owns the
/// window before exiting. A failure is harmless — the flyout then
/// stays unfocused, as before.
///
private static void AllowSetForegroundToRunningInstance()
{
try
{
var self = Environment.ProcessId;
var instances = Microsoft.Windows.AppLifecycle.AppInstance.GetInstances();
var owner = instances.FirstOrDefault(i => i.ProcessId != self && i.Key == "Llama")
?? instances.FirstOrDefault(i => i.ProcessId != self);
if (owner is not null)
AllowSetForegroundWindow(unchecked((int)owner.ProcessId));
}
catch (Exception ex)
{
Common.Log.Warn(ex, "foreground-right donation to the running instance failed");
}
}
[System.Runtime.InteropServices.DllImport("user32.dll", ExactSpelling = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
///
/// The app-wide runtime update scheduler (created in OnLaunched) —
/// Settings uses it for the Check-for-updates card.
///
internal Llama.RuntimeUpdateScheduler RuntimeUpdates => _runtimeUpdates!;
///
/// The first .gguf among a file activation's files (an "Open With"
/// activation may carry several), or null. Works for the cold-start
/// activation () and for the ones
/// redirected from a second instance (AppInstance.Activated).
///
private static string? FirstGgufFromFileActivation(object? activationData)
{
if (activationData is Windows.ApplicationModel.Activation.FileActivatedEventArgs fileArgs)
{
foreach (var file in fileArgs.Files)
if (Common.GgufFile.IsGguf(file.Path))
return file.Path;
}
return null;
}
///
/// Distills this process's activation into a handoff request: a share
/// activation's payload (shared text, a .gguf, or an image), an
/// "Open With" .gguf, or nothing.
///
/// A share's DataPackageView cannot cross a process
/// boundary, so the extraction happens in the process that received
/// the activation — the cold-start instance (which keeps the distilled
/// request and handles it after startup) or a second instance (which
/// writes it to the handoff file and exits). Per the ShareOperation
/// contract, the process that received the activation ALWAYS reports
/// it — ReportStarted around the extraction, ReportCompleted after,
/// ReportError on failure. Exiting with an unreported operation leaves
/// the in-process share host in a bad state and failed fast in
/// practice (0xC0000409 from the native share machinery).
///
private static async Task ExtractHandoffFromActivationAsync(
Microsoft.Windows.AppLifecycle.AppActivationArguments activatedArgs)
{
try
{
var data = activatedArgs.Data;
Common.Log.Debug(
$"activation kind={activatedArgs.Kind} data-type={data?.GetType().FullName ?? "null"}");
// "Open With": a file activation carries the path directly.
if (FirstGgufFromFileActivation(data) is { } gguf)
return Common.HandoffRequest.ForModel(gguf);
// A llama:// deep link (protocol activation): a chat prompt, or
// a model id to load. Parsing is strict (unknown hosts and
// oversized payloads yield no request — the link just re-opens
// the flyout), and the id is matched against the server's model
// list before anything loads.
if (activatedArgs.Kind == Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol &&
data is Windows.ApplicationModel.Activation.IProtocolActivatedEventArgs protocol)
{
Common.Log.Debug($"deep link received: {protocol.Uri}");
var link = Common.DeepLink.Parse(protocol.Uri?.ToString());
switch (link.Kind)
{
case Common.DeepLinkKind.Chat when link.Prompt is { } prompt:
return Common.HandoffRequest.ForText(prompt);
case Common.DeepLinkKind.LoadModel when link.ModelId is { } modelId:
return Common.HandoffRequest.ForModelId(modelId);
case Common.DeepLinkKind.Install when link.Repo is { } repo:
return Common.HandoffRequest.ForInstall(repo);
// llama://chat with no prompt behaves like a plain second launch.
}
return Common.HandoffRequest.None;
}
// A Windows share: text, or files (.gguf imports).
if (data is Windows.ApplicationModel.Activation.ShareTargetActivatedEventArgs share)
{
var operation = share.ShareOperation;
operation.ReportStarted();
try
{
return await ExtractSharePayloadAsync(operation);
}
catch (Exception ex)
{
Common.Log.Warn(ex, "share payload extraction failed; reporting the share as failed");
try { operation.ReportError("Llama could not read the shared content."); } catch { /* best-effort */ }
return Common.HandoffRequest.None;
}
}
}
catch (Exception ex)
{
Common.Log.Warn(ex, "share activation extraction failed");
}
return Common.HandoffRequest.None;
}
/// The payload of a reported share: text, a model file, an image
/// file, or a re-encoded raw bitmap. Assumes ReportStarted was already
/// called and the caller reports Completed/Error.
private static async Task ExtractSharePayloadAsync(
Windows.ApplicationModel.DataTransfer.ShareTarget.ShareOperation operation)
{
var package = operation.Data;
if (package.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text))
{
var text = await package.GetTextAsync();
operation.ReportCompleted();
return text.Length > 0
? Common.HandoffRequest.ForText(text)
: Common.HandoffRequest.None;
}
if (package.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.StorageItems))
{
var items = await package.GetStorageItemsAsync();
var paths = items.Select(item => item.Path).Where(p => p is not null).Cast().ToList();
// A shared .gguf is a model to load — the same pipeline
// as Open With.
var model = paths.FirstOrDefault(Common.GgufFile.IsGguf);
if (model is not null)
{
operation.ReportCompleted();
return Common.HandoffRequest.ForModel(model);
}
// Otherwise an image (.jpg/.jpeg/.png) attaches to the
// chat.
var image = paths.FirstOrDefault(Common.SharedImage.IsImagePath);
operation.ReportCompleted();
return image is not null
? Common.HandoffRequest.ForImage(image)
: Common.HandoffRequest.None;
}
// A Bitmap share (e.g. a screenshot tool that hands over
// pixels, not a file) is re-encoded as PNG into the app's
// scratch dir — the WebUI paste takes a file's bytes.
if (package.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.Bitmap))
{
var reference = await package.GetBitmapAsync();
string? imagePath = null;
try
{
using var stream = await reference.OpenReadAsync();
imagePath = await SaveSharedBitmapAsync(stream);
}
catch (Exception ex)
{
Common.Log.Warn(ex, "decoding the shared bitmap failed");
}
operation.ReportCompleted();
return imagePath is not null
? Common.HandoffRequest.ForImage(imagePath)
: Common.HandoffRequest.None;
}
operation.ReportCompleted();
return Common.HandoffRequest.None;
}
///
/// A llama://model/<id> deep link: summon the chat overlay and
/// load the model. The id is remote-controlled data (any app or site
/// can open one of these), so it must match a model the server actually
/// knows — never a download, never a path — and the load rides the same
/// POST /models/load pipeline as a click in the main window. On a cold
/// start the model list may still be loading, so the match retries for
/// a few seconds before giving up with a toast.
///
private async Task LoadModelByIdAsync(string modelId)
{
try
{
_overlay?.Summon(ShareSummonGraceMs);
// The startup model list may not be in yet — poll briefly.
for (var attempt = 0; attempt < 10; attempt++)
{
var snapshot = Llama.LlamaManager.Shared.LastModelsSnapshot;
var known = snapshot.FirstOrDefault(m => string.Equals(m.Id, modelId, StringComparison.Ordinal));
if (known is not null)
{
Common.Log.Info($"deep link: loading model '{modelId}'");
var item = new Views.ModelItem { Name = modelId, RepoName = modelId };
if (!await Llama.LlamaManager.Shared.LoadModelAsync(item))
Common.Log.Warn($"deep link: the server declined to load '{modelId}'");
return; // loaded or not, the overlay is up with the WebUI
}
await Task.Delay(500);
}
Common.Log.Warn($"deep link: model '{modelId}' is not known to the server");
Notifications.Show("Model not found",
$"'{modelId}' isn't available on this server — pick or download a model in the chat.");
}
catch (Exception ex)
{
Common.Log.Warn(ex, "deep link model load failed");
}
}
///
/// A llama://install?repo=<id> deep link: install a
/// Hugging Face repo's model — resolve the repo's quants and their
/// sizes, pick one that fits this machine (see
/// ), and download
/// it through the llama server (which fetches shards, mmproj and draft
/// sidecars itself) — mirroring the Mac app's llama://install
/// flow.
///
/// The repo id is remote-controlled (any app or site can open
/// one of these links) or user-writable (the handoff file), so it is
/// re-validated before it reaches a Hub URL. The download is
/// acknowledged immediately with a throttled progress toast carrying a
/// Cancel action (the link's only UI while the flyout is hidden); when
/// the model lands, the model list poller adds the row — nothing is
/// built here — and failures surface as a classified failure toast
/// (see ).
///
private async Task InstallRepoByIdAsync(string? repo)
{
// (a) Validate + normalize: trim, drop outer slashes, then the
// same safe-segment rules the link parser applies (defense in
// depth — the handoff file is writable outside the app).
repo = repo?.Trim().Trim('/') ?? "";
if (!Common.DeepLink.IsSafeRepo(repo))
{
Common.Log.Warn("deep link install: rejected an unsafe repo id");
Notifications.Show("Couldn't install model",
"The link's repo id isn't a valid Hugging Face repo.");
return;
}
try
{
// (b) Resolve the repo's downloadable quants and their sizes
// (GET /api/models/{repo}?blobs=true). Rejections surface as
// typed statuses so a gated repo is never reported as missing.
var hub = new HuggingFace.HubClient(Settings.Current.HuggingFaceToken);
List files;
try
{
files = await hub.ListRepoFileSizesAsync(repo);
}
catch (HuggingFace.HubSearchException ex) when (ex.Status is 401 or 403)
{
Common.Log.Warn($"deep link install: '{repo}' is gated ({ex.Status})");
Notifications.Show($"Couldn't install {repo}",
"The repo is gated — accept its license on Hugging Face (or set an access " +
"token in Settings), then open the link again.");
return;
}
catch (HuggingFace.HubSearchException ex) when (ex.Status == 404)
{
Common.Log.Warn($"deep link install: '{repo}' not found (404)");
Notifications.Show($"Couldn't install {repo}",
"No Hugging Face repo with that name was found.");
return;
}
var builds = QuantSelection.BuildsFor(repo, files);
if (builds.Count == 0)
{
Common.Log.Warn($"deep link install: '{repo}' has no downloadable GGUF quants");
Notifications.Show($"Couldn't install {repo}",
"The repo doesn't contain GGUF files with a recognizable quant tag.");
return;
}
// (c) Pick the quant: the largest one whose fit is Fit/Might
// against this machine's memory budget (free VRAM + the usable
// share of RAM), with the selector's conservative fallback
// when nothing fits or the sizes are unknown.
ulong budget = 0;
try
{
budget = await Llama.LlamaManager.Shared.ContextMemoryBudgetAsync();
}
catch (Exception ex)
{
Common.Log.Warn(ex, "deep link install: memory budget probe failed; picking blind");
}
var pick = QuantSelection.SelectQuant(builds, budget);
if (pick is null)
{
Common.Log.Warn($"deep link install: '{repo}' resolved {builds.Count} quant(s), none fit a {budget}-byte budget");
Notifications.Show($"Couldn't install {repo}",
"None of this repo's quants fit this machine's memory.");
return;
}
Common.Log.Info($"deep link install: '{repo}' resolved {builds.Count} quant(s); " +
$"chosen {pick.Quant} against a {budget}-byte budget");
// Disk-space preflight: a deep link starts a multi-GB download
// with no row to block it, so refuse up front when the cache
// drive can't hold the selected build (a failed probe never
// blocks).
if (pick.SizeBytes > 0 &&
!Common.DiskSpace.HasEnoughSpace(
Settings.Current.CacheDirectory, pick.SizeBytes, out var freeBytes))
{
Common.Log.Info($"deep link install: '{repo}' blocked — {pick.SizeBytes} bytes needed, {freeBytes} free");
Notifications.Show($"Couldn't install {repo}",
"Not enough disk space for the selected model.");
return;
}
// Cold start: the install may fire while the server is still
// coming up. DownloadModelAsync hard-fails on a non-Running
// server, so wait (bounded) for it before starting the download.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (Llama.LlamaManager.Shared.ServerStatus != Llama.LlamaManager.ServerState.Running)
{
if (Llama.LlamaManager.Shared.ServerStatus == Llama.LlamaManager.ServerState.Failed ||
Llama.LlamaManager.Shared.State == Llama.LlamaManager.InstallState.Failed ||
DateTime.UtcNow >= deadline)
break;
await Task.Delay(500);
}
if (Llama.LlamaManager.Shared.ServerStatus != Llama.LlamaManager.ServerState.Running)
{
Common.Log.Warn($"deep link install: '{repo}' — server not running, cannot download");
Notifications.Show($"Couldn't install {repo}",
"The llama server isn't running yet — try again in a moment.");
return;
}
// (d) Download through the server with a throttled progress
// toast (the row-driven counterpart lives in
// MainWindow.DownloadAndLaunchAsync).
await DownloadInstallAsync(pick);
}
catch (Exception ex)
{
Common.Log.Error(ex, $"deep link install of '{repo}' failed");
Notifications.Show($"Couldn't install {repo}",
"Llama couldn't read the repo's file list. Check the connection and try again.");
}
}
///
/// Drives one llama://install download: POSTs the
/// repo:quant to the llama server (which resolves shards and
/// sidecars itself) and mirrors the MainWindow download flow's
/// throttled progress toast — ~1 update/sec with a percent + speed
/// status line and a Cancel action — closing it on every terminal
/// path. On success nothing else happens here: the file lands in the
/// server's cache and the model list poller adds the row on its next
/// cycle. On failure the classified
/// toast explains what to do.
///
private async Task DownloadInstallAsync(HuggingFace.Repository target)
{
// Global single-flight: at most one deep-link install download at a
// time — N rapid links must not start N parallel multi-GB downloads.
if (!InstallGate.Wait(0))
{
Common.Log.Info("deep link install: another install is already in flight");
Notifications.Show("Install in progress",
"Another model is already downloading — try again when it finishes.");
return;
}
try
{
await DownloadInstallCoreAsync(target);
}
finally
{
InstallGate.Release();
}
}
private async Task DownloadInstallCoreAsync(HuggingFace.Repository target)
{
var serverId = target.ServerModelId;
// One download per repo:quant — a re-fired link while the download
// is in flight joins the existing toast instead of a second POST.
using var cts = new CancellationTokenSource();
if (!InstallDownloads.TryAdd(serverId, cts))
{
Common.Log.Info($"deep link install: {serverId} is already downloading");
return;
}
var toastTag = ToastTagForInstall(serverId);
var toastShown = false;
try
{
// Surface the download: a deep link fires with the flyout
// hidden, and the progress toast only appears with the first
// byte — without this the click feels like a no-op. The
// model-list poller turns the download into a row on its
// next cycle.
_trayIcon?.ShowFlyout();
var cancelToastAction = new ToastAction("Cancel",
("action", "cancelInstall"), ("id", serverId));
// Throttle toast updates to ~1/sec — each one is a
// notification-platform call — and EMA-smooth the speed
// between samples (same shape as DownloadAndLaunchAsync).
long lastToastMs = 0, lastSampleMs = 0, lastSampleBytes = 0;
double bytesPerSecond = 0;
Common.ModelDownloadProgress? failureReport = null;
var progress = new Progress(p =>
{
if (p.Failed) { failureReport = p; return; } // terminal — classified after the await
if (p.Done || p.TotalBytes <= 0) return;
var now = Environment.TickCount64;
if (now - lastToastMs < 1000) return;
lastToastMs = now;
if (lastSampleMs != 0 && p.DownloadedBytes > lastSampleBytes)
{
var instantaneous = (p.DownloadedBytes - lastSampleBytes)
* 1000.0 / Math.Max(1, now - lastSampleMs);
bytesPerSecond = DownloadProgressPresentation.SmoothSpeed(bytesPerSecond, instantaneous);
}
lastSampleBytes = p.DownloadedBytes;
lastSampleMs = now;
toastShown = true;
Notifications.ShowProgress(toastTag,
"Downloading " + serverId,
InstallToastStatus(p.Fraction, bytesPerSecond),
p.Fraction, cancelToastAction);
});
Common.Log.Info($"deep link install: downloading {serverId}");
var ok = await Llama.LlamaManager.Shared.DownloadModelAsync(target, progress, cts.Token);
if (ok)
{
Common.Log.Info($"deep link install: {serverId} downloaded — " +
"the model list poller adds the row on its next cycle");
return;
}
var failure = DownloadFailurePresentation.Classify(
failureReport?.HttpStatus, failureReport?.Message, failureReport?.ExceptionType);
Common.Log.Warn($"deep link install: {serverId} failed — {failure.RawDetail ?? failure.Headline}");
Notifications.Show("Download failed",
DownloadFailurePresentation.ToastBody(serverId, failure));
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
// Cancel button on the progress toast — the server has been
// asked to abort and drops the partial bytes.
Common.Log.Info($"deep link install: {serverId} canceled");
}
catch (OperationCanceledException ex)
{
// A timeout (the 30 s POST budget's TaskCanceledException, not
// the user's Cancel) — surface it instead of vanishing.
Common.Log.Error(ex, $"deep link install: download of {serverId} timed out");
var failure = DownloadFailurePresentation.Classify(null, "The download timed out.", "Timeout");
Notifications.Show("Download failed",
DownloadFailurePresentation.ToastBody(serverId, failure));
}
catch (Exception ex)
{
Common.Log.Error(ex, $"deep link install: download of {serverId} failed unexpectedly");
var failure = DownloadFailurePresentation.Classify(null, ex.Message, ex.GetType().Name);
Notifications.Show("Download failed",
DownloadFailurePresentation.ToastBody(serverId, failure));
}
finally
{
// Unregister + close before the `using` disposes so a late
// Cancel click can never reach a disposed source.
if (toastShown) Notifications.Close(toastTag);
InstallDownloads.TryRemove(serverId, out _);
}
}
///
/// A bounded toast tag for an install download: the platform toast tag
/// has a short length limit, and the repo id is remote-controlled (up
/// to 200 chars), so truncate plus a stable short hash keeps tags unique
/// without overflowing the notification API.
///
private static string ToastTagForInstall(string serverId)
{
const int head = 40;
var prefix = serverId.Length > head ? serverId[..head] : serverId;
return "install:" + prefix + "#" +
serverId.GetHashCode(StringComparison.Ordinal).ToString("x");
}
///
/// Cancels an in-flight llama://install download from its
/// progress toast's Cancel button. A stale toast (the download already
/// finished and cleaned up) is a no-op.
///
private static void CancelInstallFromToast(string? serverModelId)
{
try
{
if (serverModelId is not null && InstallDownloads.TryGetValue(serverModelId, out var cts))
cts.Cancel();
}
catch (Exception ex)
{
// The download finished (or is finishing) between the lookup
// and the cancel — either way there's nothing left to cancel.
Common.Log.Warn(ex, "deep link install: cancel failed (download may already be gone)");
}
}
///
/// The install progress toast's status line — percent + smoothed
/// speed, e.g. "42% · 12.3 MB/s" (the same format as the row-driven
/// download toast; a stalled stream shows just the percent).
///
private static string InstallToastStatus(double fraction, double bytesPerSecond)
{
var pct = (int)Math.Round(fraction * 100);
return bytesPerSecond > 0
? $"{pct}% · {DownloadProgressPresentation.FormatBytes(bytesPerSecond)}/s"
: $"{pct}%";
}
///
/// Shared text lands in the chat overlay: the overlay opens (Summon)
/// and the text is handed to it as a pending prompt, which the overlay
/// injects into the WebUI's composer as soon as one exists (a share
/// that arrives before any model is loaded waits until the chat
/// screen is up — see OverlayWindow.SetPendingPrompt).
///
private async Task HandleSharedPromptAsync(string prompt)
{
try
{
Common.Log.Info($"shared text received ({prompt.Length} chars); opening the chat overlay");
_overlay?.Summon(ShareSummonGraceMs);
_overlay?.SetPendingPrompt(prompt);
}
catch (Exception ex)
{
Common.Log.Warn(ex, "handling the shared prompt failed");
}
await Task.CompletedTask;
}
///
/// A programmatic overlay summon ignores focus loss for this long: the
/// dismissing share sheet races the summon for focus and can
/// activate-then-deactivate the overlay repeatedly, which used to flash
/// it shut before the user ever saw it. Long enough to outlast the sheet's
/// teardown; short enough that click-away still dismisses normally after.
///
private const int ShareSummonGraceMs = 3000;
///
/// A shared image attaches to the chat overlay: the overlay opens and
/// the image is queued as a pending attachment (a synthetic paste
/// into the WebUI's composer, gated on an image-capable model — see
/// ). When no image-capable
/// model is loaded, one is loaded for the share — the user's intent is
/// "ask about this image", which needs vision — preferring models the
/// user has used before (a saved context length), smallest first; the
/// overlay's retry loop pastes once the model is resident.
///
private async Task HandleSharedImageAsync(string imagePath)
{
try
{
Common.Log.Info($"shared image received '{System.IO.Path.GetFileName(imagePath)}'; opening the chat overlay");
_overlay?.Summon(ShareSummonGraceMs);
_overlay?.SetPendingImage(imagePath);
_ = EnsureVisionModelForShareAsync();
}
catch (Exception ex)
{
Common.Log.Warn(ex, "handling the shared image failed");
}
await Task.CompletedTask;
}
///
/// Loads an image-capable model for an incoming shared image when no
/// suitable model is loaded: prefer the loaded model when it already
/// accepts images, else prefer models the user has configured a context
/// length for (a trace of prior use), smallest on-disk model first so
/// the pick fits the GPU and loads fast. Fire-and-forget — the overlay's
/// paste retry loop delivers once the model is resident.
///
private async Task EnsureVisionModelForShareAsync()
{
try
{
var snapshot = Llama.LlamaManager.Shared.LastModelsSnapshot;
if (snapshot.FirstOrDefault(m => m.IsLoaded && m.SupportsImage) is not null)
return; // already usable — the retry loop will paste
var candidates = snapshot
.Where(m => m.SupportsImage && !m.IsLoading && !m.IsDownloading)
.ToList();
if (candidates.Count == 0)
{
Common.Log.Warn("no image-capable model is available for the shared image; " +
"the overlay will wait for the user to load one");
return; // the wait-and-notify path remains
}
var preferred = candidates
.Where(m => Settings.Current.ModelContextLengths.ContainsKey(m.Id))
.ToList();
var pool = preferred.Count > 0 ? preferred : candidates;
var choice = pool
.OrderBy(m => System.IO.File.Exists(m.Path) ? new System.IO.FileInfo(m.Path).Length : long.MaxValue)
.First();
Common.Log.Info($"loading image-capable model '{choice.Id}' for the shared image");
var item = new Views.ModelItem { Name = choice.Id, RepoName = choice.Id };
if (!await Llama.LlamaManager.Shared.LoadModelAsync(item))
Common.Log.Warn($"the server declined to load '{choice.Id}' for the shared image");
// Loaded or not: the overlay's retry loop decides delivery, and
// the poller refreshes LoadedModelSupportsImages within ~1s.
}
catch (Exception ex)
{
Common.Log.Warn(ex, "auto-loading a vision model for the share failed");
}
}
///
/// Re-encodes a shared
/// stream as PNG into the app's scratch dir
/// (%LOCALAPPDATA%\Llama\shared, swept on delivery). Returns the
/// written path, or null when the pixels couldn't be decoded (some
/// sources share raw DIB data the decoder can't read).
///
private static async Task SaveSharedBitmapAsync(
Windows.Storage.Streams.IRandomAccessStream stream)
{
var dir = Views.OverlayWindow.ScratchImageDir;
Directory.CreateDirectory(dir);
var path = System.IO.Path.Combine(dir, $"shared-{Guid.NewGuid():N}.png");
var decoder = await Windows.Graphics.Imaging.BitmapDecoder.CreateAsync(stream);
using var softwareBitmap = await decoder.GetSoftwareBitmapAsync();
using var fileStream = File.Open(path, FileMode.CreateNew);
using var winrtStream = fileStream.AsRandomAccessStream();
var encoder = await Windows.Graphics.Imaging.BitmapEncoder.CreateAsync(
Windows.Graphics.Imaging.BitmapEncoder.PngEncoderId, winrtStream);
encoder.SetSoftwareBitmap(softwareBitmap);
await encoder.FlushAsync();
return path;
}
///
/// An unpackaged LlamaApp.exe C:\path\model.gguf launch (dev
/// runs, scripts) carries the path on the command line — WinUI 3's
/// LaunchActivatedEventArgs.Arguments is empty there, so argv is read
/// directly. Quotes are tolerated; a non-existing file is ignored
/// (the arguments are free-form).
///
private static string? FirstGgufOnCommandLine()
{
var argv = Environment.GetCommandLineArgs();
for (var i = 1; i < argv.Length; i++)
{
var candidate = argv[i].Trim().Trim('"');
if (Common.GgufFile.IsGguf(candidate) && File.Exists(candidate))
return candidate;
}
return null;
}
///
/// Handles "Open With → Llama" on a .gguf: shows the flyout, ensures
/// the llama server is up, links the file into the server's
/// --models-dir (where the router discovers it as a model named
/// after the file), refreshes the model list so the router registers
/// the import, and loads it. On success the chat overlay opens on the
/// model; failures surface as toasts with actionable text.
///
private async Task OpenModelFileAsync(string modelPath)
{
var fileName = System.IO.Path.GetFileName(modelPath);
try
{
// Something visibly happens immediately (loads take a while).
_trayIcon?.ShowFlyout();
// The server may need installing/starting (first ever use can
// be triggered by opening a model file).
var reachable = await Llama.LlamaManager.Shared.EnsureLlamaOrDownloadAsync();
if (!reachable)
{
Notifications.Show($"Couldn't open {fileName}",
"The llama server isn't running. Open the Llama flyout and check the server status, then try again.");
return;
}
var import = Llama.LlamaManager.ImportLocalModel(modelPath);
if (import.Outcome == Llama.LocalModelImportOutcome.Copied)
Notifications.Show($"Copying {fileName}",
"The file lives on another drive, so it was copied into Llama's models folder — the model loads next.");
// The router rescans its model sources when asked (older
// servers only on GET /models?reload=1) — the refresh registers
// the import, making its id loadable (a load without it would
// answer 404 model-is-not-found).
await Llama.LlamaManager.Shared.RefreshModelsAsync();
// Embedded MTP head (issue #97): builds that bake the head
// into the main weights tag their filename with an mtp token
// (…-Q4_K_M-mtp.gguf). The server's models-dir scan wires
// sidecar files by itself, but not embedded heads — the
// spec-type entry for those comes from the app. Best-effort.
if (Common.DraftHeads.HasEmbeddedMtp(fileName))
{
// Under the dictionary's lock — the same shared settings
// dictionary the draft-head poller and the INI renderer
// touch from background threads (see DraftHeadDetection.Apply).
var specTypes = Settings.Current.ModelSpeculativeTypes;
lock (specTypes)
specTypes[import.ModelId] =
Common.DraftHeads.SpecType(Common.DraftHeadKind.Mtp);
Settings.Current.Save();
await Llama.LlamaManager.Shared.ReloadModelPresetsAsync();
Common.Log.Info($"draft heads: '{fileName}' carries an embedded MTP head — speculative decoding enabled");
}
// ServerModelId of a file-imported model is its id (no
// repo:quant shape — see LocalModelImport.DeriveModelId).
var model = new Views.ModelItem { Name = import.ModelId, RepoName = import.ModelId };
var loaded = await Llama.LlamaManager.Shared.LoadModelAsync(model);
if (loaded)
{
Common.Log.Info($"opened model file '{modelPath}' as '{import.ModelId}'");
SummonChatOverlay();
}
else
{
Common.Log.Warn($"open-with: the server rejected model '{import.ModelId}' ({modelPath})");
Notifications.Show($"Couldn't load {fileName}",
"The llama server rejected the model. It may be an unsupported or damaged GGUF file.");
}
}
catch (Exception ex)
{
// The importer throws actionable IOExceptions (file missing,
// no space for a cross-volume copy) — the message is the toast.
Common.Log.Error(ex, $"open-with of '{modelPath}' failed");
Notifications.Show($"Couldn't open {fileName}", ex.Message);
}
}
///
/// Opens the chat overlay — the Alt+Space panel — from surfaces that
/// don't own the hotkey (toast Chat button, tray context menu).
///
internal void SummonChatOverlay() => _overlay?.Summon(ShareSummonGraceMs);
///
/// Responds to a toast activation on the UI thread: a body click keeps
/// the long-standing behavior (open the flyout); an action button's
/// arguments name the response — retry/cancel a download by model
/// id (the MainWindow resolves the row), or open the chat overlay.
/// Unknown arguments fall back to the flyout rather than doing
/// nothing — a stale toast from a previous version should still go
/// somewhere useful.
///
private void HandleToastActivation(IReadOnlyDictionary args)
{
if (args.TryGetValue("action", out var action))
{
args.TryGetValue("id", out var id);
switch (action)
{
case "chat":
_overlay?.Summon();
return;
case "retryDownload":
_window?.RetryDownloadFromToast(id);
return;
case "cancelDownload":
_window?.CancelDownloadFromToast(id);
return;
case "cancelInstall":
CancelInstallFromToast(id);
return;
case "reloadDraftHead":
// A draft head was detected on a loaded model (issue
// #97); the reload respawns the child with its
// spec-type so the head engages now.
if (!string.IsNullOrWhiteSpace(id))
_ = DraftHeadDetection.ReloadForDraftHeadsAsync(id);
return;
}
}
_trayIcon?.ShowFlyout();
}
}
}