Plugin
Developer Docs
FocusTracker tiene un sistema de plugins de carga dinámica que te permite extender la aplicación con comportamientos propios: nuevas pantallas de datos, botones en cualquier sección, ciclos automáticos, integraciones con servicios externos y más — todo sin modificar el código base.
Cada plugin se carga en un PluginLoadContext aislado. El código del plugin nunca accede directamente a la base de datos, la capa WPF ni ningún servicio interno. Toda la interacción ocurre a través de IPluginContext, que actúa como facade controlado. Los binarios son validados en múltiples pasos antes de ejecutar una sola instrucción.
Pipeline de carga
Cuando el host carga un .focusplugin, ejecuta estos pasos en orden. Si alguno falla, el plugin es rechazado con un mensaje claro en crash.log — sin afectar al resto de la app:
- Lee plugin.json — sin tocar ningún DLL todavía.
- Valida el manifiesto — campos requeridos, formato del Id, versión del host.
- Extrae el archivo — en un directorio temporal con protección path-traversal.
- Carga el ensamblado en un
PluginLoadContextcoleccionable. - Busca la implementación de
IFocusPluginpor reflexión. - Instancia la clase vía constructor sin parámetros.
- Verifica el Id — debe coincidir exactamente con el manifiesto.
- Llama a GetUserHelp() — valida que no sea null ni tenga Summary vacío.
- Llama a Initialize() — aquí empieza tu código.
Inicio rápido
De cero a un plugin funcionando en menos de 10 minutos usando el template oficial.
Prerrequisitos
- .NET 8 SDK — descargar en dotnet.microsoft.com
- PowerShell 5.1+ — incluido en Windows 10/11
- FocusTracker ≥ 1.4.0 instalado para probar localmente
- Cualquier editor: Visual Studio, VS Code + C# Dev Kit, o Rider
Pasos
-
Descargá el template
Clona o descargá el template desde ESTE ENLACE. Tiene el SDK preconfigurado, elplugin.jsony el script de build. -
Editá tu identidad en
plugin.json
Cambiáid,name,version,authorydescription. Elidnunca puede cambiar entre versiones.{ "id": "com.tudominio.mi-plugin", "name": "Mi Plugin", "version": "1.0.0", "author": "Tu Nombre", "description": "Una descripción corta (≤140 chars).", "minHostVersion": "1.4.0", "entryAssembly": "MiPlugin.dll" } -
Implementá
IFocusPlugin
RenombráMyPlugin.csy completá los cinco campos de identidad, el métodoGetUserHelp()(obligatorio) y tu lógica enInitialize/Shutdown. Ver la sección para la referencia completa. -
Empaquetá con
build.ps1
Desde PowerShell en la raíz del template:
Genera.\build.ps1dist/com.tudominio.mi-plugin-1.0.0.focusplugin. -
Instalá y probá
En FocusTracker → Plugins → Importar, seleccioná el.focusplugin. El plugin aparece al instante en Mis plugins. Si algo falla, revisá%APPDATA%\FocusTracker\crash.log.
Usá .\build.ps1 -Configuration Debug para incluir símbolos de debug. Para CI/CD, agregá -NoPause y el script no espera input del usuario al terminar.
Estructura del proyecto
Un plugin es una DLL de .NET 8 empaquetada en un ZIP con extensión .focusplugin.
Árbol de archivos del template
sdk/ ← SDK (no modificar)
IFocusPlugin.cs
IPluginContext.cs
PluginContribution.cs
PluginSettingDescriptor.cs
SdkModels.cs
src/ ← tu código aquí
MyPlugin.cs
PluginConfig.cs
MyPlugin.csproj
plugin.json ← manifiesto
build.ps1 ← script de empaquetado
Contenido del paquete .focusplugin
| Archivo | Descripción | |
|---|---|---|
| plugin.json | requerido | Manifiesto con metadatos e Id del plugin. |
| {EntryAssembly}.dll | requerido | DLL con la clase que implementa IFocusPlugin. |
| FocusTracker.PluginSDK.dll | excluir | No incluir — el host provee el SDK. El script de build lo omite automáticamente. |
| Dependencias propias | opcional | DLLs de NuGet que uses. El build las copia, salvo SDK y BCL de .NET. |
Si empaquetás FocusTracker.PluginSDK.dll dentro del .focusplugin, el host terminará con dos versiones del SDK en memoria y los casteos de tipos fallarán. El script build.ps1 ya excluye el SDK, Microsoft.* y System.* automáticamente.
Carpeta de extracción en el host
El host extrae cada plugin en %APPDATA%\FocusTracker\plugins\_extracted\{id}\. Esta carpeta se limpia y re-extrae cada vez que se instala una nueva versión, por lo que no necesitás hacer nada manual.
IFocusPlugin
Toda clase plugin debe implementar esta interfaz. El host la descubre por reflexión y requiere un constructor público sin parámetros.
public class MiPlugin : IFocusPlugin
{
// ── Identidad ──────────────────────────────────────────────────
public string Id => "com.tudominio.mi-plugin";
public string Name => "Mi Plugin";
public string Version => "1.0.0";
public string Author => "Tu Nombre";
public string Description => "Descripción corta (≤140 chars).";
// ── Ayuda (OBLIGATORIO) ────────────────────────────────────────
public PluginHelpContent GetUserHelp() => new()
{
Summary = "Qué hace el plugin.",
Sections = new[] { new PluginHelpSection { Heading = "Uso", Body = "..." } }
};
// ── Ciclo de vida ──────────────────────────────────────────────
public void Initialize(IPluginContext context) { /* tu lógica */ }
public void Shutdown() { /* cleanup */ }
}
Identidad
string Id permanente"com.empresa.plugin"). Nunca puede cambiar entre versiones. El host rechaza plugins cuyo Id en código no coincida exactamente con el de plugin.json.string Namestring Versionversion de plugin.json y el <Version> del .csproj.string Authorstring DescriptionDocumentación — obligatoria
PluginHelpContent GetUserHelp() requeridoInitialize() — debe ser pura y sin estado. Si retorna null o el Summary está vacío, el plugin es rechazado. Ver referencia completa →Ciclo de vida
void Initialize(IPluginContext context)context, suscribite a eventos y registrá tus contribuciones de UI aquí.void Shutdown()El host puede llamar a Shutdown() desde cualquier thread. Si tenés timers de System.Timers.Timer, detelos y esperá a que el callback actual termine antes de liberar el estado.
plugin.json
El host lee este archivo antes de abrir cualquier DLL. Si la validación falla, el paquete es rechazado sin ejecutar código.
{
"id": "com.tudominio.mi-plugin",
"name": "Mi Plugin",
"version": "1.0.0",
"author": "Tu Nombre",
"description": "Descripción corta (≤140 chars).",
"minHostVersion": "1.4.0",
"entryAssembly": "MiPlugin.dll"
}
Campos
| Campo | Descripción | |
|---|---|---|
| id | requerido | Reverse-DNS. Solo letras, dígitos, puntos, guiones y guiones bajos. Máx. 128 chars. Permanente. |
| name | requerido | Nombre legible para la UI. |
| version | requerido | Semver (ej: "1.2.0"). Debe coincidir con Version en código y <Version> en el csproj. |
| author | opcional | Nombre del autor u organización. |
| description | opcional | ≤140 chars. Mostrada en el marketplace antes de instalar. |
| minHostVersion | requerido | Versión mínima de FocusTracker. Si el usuario tiene una versión anterior, el plugin es rechazado con mensaje claro. |
| entryAssembly | requerido | Nombre del archivo DLL dentro del paquete (ej: "MiPlugin.dll"). |
El campo id es la clave primaria del plugin en todo el sistema. Cambiarlo en una actualización equivale a crear un plugin nuevo — los usuarios perderían su configuración y el historial de instalación.
Versión consistente en los tres lugares
La versión del plugin debe estar sincronizada en:
plugin.json→ campo"version"MiPlugin.cs→ propiedadVersion => "1.0.0"MiPlugin.csproj→<Version>1.0.0</Version>
El script build.ps1 usa el version del plugin.json para nombrar el archivo de salida, así que si hay discrepancia el paquete generado tendrá un nombre inconsistente.
IPluginContext
El host entrega una instancia de IPluginContext en Initialize(). Todos los métodos son thread-safe; los callbacks de eventos se ejecutan en el hilo de UI.
Estado de tracking
bool IsTrackingtrue mientras hay una sesión activa.string? GetCurrentFocusedApp()null.Eventos
event Action<int?>? TrackingStartednull para sesiones libres.event Action? TrackingStoppedevent Action<string, TimeSpan>? FocusChangedevent Action<string, string>? AlarmTriggeredevent Action? IdleDetectedpublic void Initialize(IPluginContext context)
{
_ctx = context;
context.TrackingStarted += OnStart;
context.TrackingStopped += OnStop;
context.FocusChanged += OnFocus;
}
public void Shutdown()
{
if (_ctx is not null)
{
_ctx.TrackingStarted -= OnStart;
_ctx.TrackingStopped -= OnStop;
_ctx.FocusChanged -= OnFocus;
}
}
Lectura de sesiones
List<TrackingSession> GetAllSessions()List<TrackingSession> GetSessionsInRange(DateTime from, DateTime to)TrackingSession? GetCurrentSession()null si no hay tracking.List<AppUsageSummary> GetSessionDetail(int sessionId)Lectura de uso y tiempo
List<AppUsageSummary> GetUsageSummaries(DateTime from, DateTime to, int? projectId)List<AppUsageSummary> GetTodaySummaries(int? projectId)TimeSpan GetTotalTrackedTime(DateTime from, DateTime to, int? projectId)Lectura de proyectos
List<Project> GetAllProjects()Project? GetProject(int id)null.(main, extras) GetProjectSummaries(Project, DateTime, DateTime)main: apps explícitamente rastreadas. extras: apps detectadas en sesiones del proyecto pero no en sus claves.Configuración del host
string GetDataFolder()bool GetNotificationSoundEnabled()true si el usuario tiene sonido de notificaciones activo.bool GetIdleDetectionEnabled()true si la detección de inactividad está activa.Escritura de proyectos
Las operaciones de escritura modifican datos persistentes del usuario. Documentá lo que hace tu plugin y obtené confirmación antes de borrar sesiones o proyectos.
int CreateProject(string name, string[] trackedKeys, int? totalAlarmMins, int? sessionAlarmMins)trackedKeys: nombres de proceso (ej: "figma") o hosts URL (ej: "notion.so").void UpdateProject(int id, string name, string[] trackedKeys, ...)void DeleteProject(int id)Escritura de sesiones y tracking
void RenameSession(int sessionId, string name)void AssignSessionToProject(int sessionId, int? projectId)null como projectId desvincula la sesión.void DeleteSession(int sessionId)void AddTrackedApp(string processName, string displayName, bool trackUnfocus)processName sin .exe, en minúsculas. No-op si no hay tracking.void AddTrackedUrl(string host, string label, bool trackUnfocus)Modelos de datos
| Propiedad | Tipo | Descripción |
|---|---|---|
| TrackingSession | ||
| Id | int | Identificador único. |
| DisplayName | string | Usa Name si está definido, sino "Session #Id". |
| StartTime / EndTime | DateTime / DateTime? | EndTime es null si la sesión está activa. |
| ProjectId | int? | ID del proyecto asignado, o null. |
| AppUsageSummary | ||
| AppDisplayName | string | Nombre legible de la app o URL. |
| FocusSeconds / UnfocusSeconds | long | Tiempo en primer plano vs. abierto sin foco. |
| TotalTime | TimeSpan | Suma de foco + desfoco. |
| FormattedTime | string | Tiempo formateado listo para mostrar (ej: "1h 5m 30s"). |
Botones y tarjetas
Un plugin puede agregar botones a cualquier pantalla del host y una tarjeta de datos a la pantalla de inicio.
Botones en pantallas
Los botones aparecen en una fila dedicada debajo de los controles nativos de cada pantalla.
context.RegisterButton(new PluginButtonContribution
{
Screen = PluginScreenTarget.Home,
Label = "Mi acción",
Icon = "\uE768", // Segoe Fluent Icons — opcional
Style = PluginButtonStyle.Primary,
OnClicked = () => DoSomething(),
// Visibilidad condicional — evaluada cada segundo
GetIsVisible = () => context.IsTracking,
});
Pantallas disponibles (PluginScreenTarget)
| Valor | Descripción |
|---|---|
| Home | Pantalla de inicio. |
| Dashboard | Panel de análisis de uso. |
| Sessions | Lista de sesiones grabadas. |
| Projects | Gestión de proyectos. |
| LiveTracking | Overlay visible mientras la sesión corre. |
Estilos de botón (PluginButtonStyle)
| Estilo | Apariencia | Cuándo usarlo |
|---|---|---|
| Primary | Fondo verde lima #C8FF00 | CTA principal, acción más importante. |
| Default | Borde gris, fondo transparente | Acciones generales y neutras. |
| Secondary | Borde naranja #FF8C42 | Acciones secundarias o de modificación. |
| Tertiary | Borde azul #4DA6FF | Acciones informativas o de navegación. |
| Danger | Borde rojo #FF4D6A | Acciones destructivas (stop, eliminar). |
GetIsVisible es un Func<bool> evaluado periódicamente por el host. Si devuelve false, el botón queda colapsado. Útil para mostrar acciones solo cuando son relevantes, como el botón "+5 min" del Pomodoro que solo aparece cuando hay un ciclo activo. Si es null (por defecto), el botón siempre está visible.
Tarjeta en Home
Una tarjeta es un widget de datos en la pantalla de inicio, con filas clave-valor y auto-refresco configurable.
context.RegisterHomeCard(new PluginCardContribution
{
Title = "Mi Plugin",
Icon = "\uE9D9",
AutoRefreshSeconds = 5, // 0 = estático
GetRows = BuildRows,
});
private IReadOnlyList<PluginCardRow> BuildRows() => new[]
{
new PluginCardRow { Label = "Estado", Value = "Activo" },
new PluginCardRow { Label = "DETALLES", IsHeader = true },
new PluginCardRow { Label = "Restante", Value = "18:32" },
};
Devolvé una lista vacía para que el host muestre el estado vacío por defecto. Las filas con IsHeader = true se renderizan en negrita y sin columna de valor, útiles para agrupar datos.
Ajustes en Settings
Tu plugin puede agregar items de configuración a la pantalla de Ajustes. El host crea automáticamente una pestaña con el nombre que indiques.
Tipos de control
| Tipo | Descripción | Callback |
|---|---|---|
| Toggle | Switch on/off. | OnToggleChanged: Action<bool> |
| TextInput | Campo de texto. Se confirma al presionar Enter o perder el foco. | OnTextCommitted: Action<string> |
| Select | Dropdown con lista fija de opciones. | OnSelectChanged: Action<string> |
| Button | Botón de acción. Estilo rojo con ButtonIsDanger = true. | OnButtonClicked: Action |
| FilePicker | Ruta de archivo + botón "Browse…". Filtro Win32 configurable. | OnPathSelected: Action<string> |
| FolderPicker | Ruta de carpeta + botón "Browse…". | OnPathSelected: Action<string> |
Ejemplos
// Toggle
context.RegisterSetting(new PluginSettingDescriptor
{
TabName = "Mi Plugin",
Title = "Activar alertas",
Description = "Muestra una notificación al cambiar la app.",
Type = PluginSettingType.Toggle,
ToggleDefault = true,
OnToggleChanged = v => { _config.Enabled = v; _config.Save(...); },
});
// Select
context.RegisterSetting(new PluginSettingDescriptor
{
TabName = "Mi Plugin",
Title = "Tipo de alerta",
Type = PluginSettingType.Select,
SelectOptions = new[] { "Sonido", "Visual", "Ambos" },
SelectDefaultIndex = 2,
OnSelectChanged = v => _config.Mode = v,
});
// TextInput
context.RegisterSetting(new PluginSettingDescriptor
{
TabName = "Mi Plugin",
Title = "Prefijo",
Type = PluginSettingType.TextInput,
TextPlaceholder = "ej: 🔍 Ahora:",
TextDefault = _config.Prefix,
OnTextCommitted = v => _config.Prefix = v,
});
// Botón peligroso
context.RegisterSetting(new PluginSettingDescriptor
{
TabName = "Mi Plugin",
Title = "Resetear datos",
Type = PluginSettingType.Button,
ButtonLabel = "Eliminar todo",
ButtonIsDanger = true,
OnButtonClicked = () => ResetAll(),
});
Podés agregar un item con Type = PluginSettingType.Label (solo Title + Description, sin control) para crear encabezados de sección dentro de tu pestaña de ajustes.
Notificaciones y sonido
El host ofrece cuatro variantes para notificar al usuario. Todas se muestran como toasts en la esquina de la pantalla, con el diseño visual de FocusTracker.
Métodos disponibles
ShowNotification(title, message, kind)ShowPersistentNotification(title, message, kind, actions)ShowNotificationWithSound(title, message, kind, sound)PlaySound(sound)// Toast simple
_ctx.ShowNotification("¡Hecho!", "La tarea se completó.");
// Toast con botones de decisión
_ctx.ShowPersistentNotification(
"¿Tomás un descanso?",
"Completaste 25 minutos de trabajo.",
PluginToastKind.Success,
new[]
{
new PluginToastAction { Label = "Descanso", OnClicked = StartBreak },
new PluginToastAction { Label = "Seguir", OnClicked = ContinueWork },
new PluginToastAction { Label = "+ 5 min", OnClicked = AddFiveMin },
});
Tipos de toast (PluginToastKind)
| Valor | Color | Cuándo usarlo |
|---|---|---|
| Success | Verde lima | Acción completada, ciclo terminado, logro. |
| Info | Azul | Información neutral, recordatorio. |
| Warning | Naranja | Advertencia que requiere atención. |
| Error | Rojo | Error o fallo. |
Sonidos disponibles (WindowsSystemSound)
| Valor | Sonido del sistema |
|---|---|
| Asterisk | Información (suave, neutro) |
| Beep | Beep genérico |
| Exclamation | Exclamación (énfasis moderado) |
| Hand | Critical Stop (énfasis fuerte) |
| Question | Pregunta del sistema |
Pantalla de ayuda
Todos los plugins deben implementar GetUserHelp(). El contenido se muestra en Plugins → Mis plugins → Ayuda, para que los usuarios entiendan cómo usar tu plugin sin salir de la app.
GetUserHelp() es miembro de la interfaz IFocusPlugin, por lo que cualquier clase que no lo implemente no compilará. Además, el loader verifica en runtime que el retorno no sea null y que Summary no esté vacío — si falla, el plugin es rechazado con mensaje claro en crash.log.
public PluginHelpContent GetUserHelp() => new()
{
Summary =
"Un párrafo que explique claramente qué hace el plugin " +
"y cuál es el beneficio principal para el usuario.",
Sections = new[]
{
new PluginHelpSection
{
Heading = "Primeros pasos",
Body = "Qué debe hacer el usuario para empezar."
},
new PluginHelpSection
{
Heading = "Configuración",
Body = "Descripción de cada ajuste disponible en Settings."
},
// Agregar tantas secciones como sean necesarias.
}
};
Reglas
- Summary requerido — no puede ser null ni estar vacío. Al menos una oración.
- Método puro y sin estado — se llama antes de
Initialize(). No uses campos privados ni accedas a_ctx. - Sin límite de secciones — agregá las que necesités. Una sola es válida.
- Solo texto plano — el Body no soporta markdown ni HTML.
Escribí el contenido en el idioma principal de tus usuarios. El Summary debe poder leerse en 10 segundos y responder "¿para qué sirve?". Cada sección debería responder una pregunta concreta: cómo activar, qué configura cada ajuste, qué esperar al usar el plugin.
Persistir configuración
El template incluye PluginConfig — un patrón simple de serialización JSON. Usalo directamente o adaptalo con tus propios campos.
// PluginConfig.cs
public class PluginConfig
{
public bool EnableAlerts { get; set; } = true;
public string Prefix { get; set; } = "";
public static PluginConfig Load(string folder, string pluginId)
{
var path = Path.Combine(folder, $"plugin_{pluginId}.json");
if (!File.Exists(path)) return new();
try { return JsonSerializer.Deserialize<PluginConfig>(File.ReadAllText(path)) ?? new(); }
catch { return new(); }
}
public void Save(string folder, string pluginId) =>
File.WriteAllText(
Path.Combine(folder, $"plugin_{pluginId}.json"),
JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
Uso en Initialize()
private IPluginContext? _ctx;
private PluginConfig _config = new();
public void Initialize(IPluginContext context)
{
_ctx = context;
_config = PluginConfig.Load(context.GetDataFolder(), Id);
context.RegisterSetting(new PluginSettingDescriptor {
ToggleDefault = _config.EnableAlerts,
OnToggleChanged = v => {
_config.EnableAlerts = v;
_config.Save(context.GetDataFolder(), Id);
},
// ...
});
}
Los archivos de config se guardan en %APPDATA%\FocusTracker\ con el nombre plugin_{id}.json. Son archivos de texto plano que el usuario puede respaldar o migrar.
Empaquetar el plugin
El script build.ps1 compila, empaqueta y genera el .focusplugin listo para distribuir en un solo comando.
Uso
# Release (por defecto)
.\build.ps1
# Debug — incluye símbolos para depurar
.\build.ps1 -Configuration Debug
# Sin pausa al terminar — ideal para CI/CD
.\build.ps1 -NoPause
El archivo de salida se genera en dist/{id}-{version}.focusplugin.
Qué hace el script
- Compila el SDK en el modo indicado.
- Compila tu plugin referenciando el SDK.
- Empaqueta: copia el DLL del plugin,
plugin.jsony tus dependencias propias (excluye SDK,Microsoft.*ySystem.*) en un ZIP renombrado a.focusplugin.
Instalar para probar
En FocusTracker → Plugins → Importar, seleccioná el .focusplugin generado. El plugin aparece inmediatamente en Mis plugins. Si algo falla, revisá:
%APPDATA%\FocusTracker\crash.log
Para actualizar una versión instalada, simplemente volvé a importar el nuevo .focusplugin. El host sobrescribe el archivo anterior, re-extrae y recarga el plugin en el mismo ciclo.
Checklist final
Antes de compartir o publicar tu plugin, verificá cada punto de esta lista.
-
Id único y permanente
TuIden código coincide exactamente con elidenplugin.json. Usás reverse-DNS. Solo letras, dígitos, puntos, guiones y guiones bajos. -
Versión consistente en los tres archivos
Versionen código =versionenplugin.json=<Version>en el.csproj. -
Description ≤ 140 caracteres
Tanto el campoDescriptionen código como eldescriptionenplugin.json. -
GetUserHelp() implementado y completo
Summaryno vacío. Al menos una sección explicando cómo usar el plugin. Sin dependencia de estado. -
Shutdown() desuscribe todos los eventos
Cada+=tiene su-=correspondiente. Probalo desactivando el plugin desde Mis plugins y volviéndolo a activar. -
Thread safety
Si usásSystem.Timers.Timeru otros threads, el estado mutable está protegido conlock. -
SDK excluido del paquete
Verificá queFocusTracker.PluginSDK.dllno esté dentro del.focusplugin. -
Probado localmente
Instalado vía Importar, activado, desactivado y vuelto a activar sin errores en%APPDATA%\FocusTracker\crash.log.
Desactivá tu plugin desde Mis plugins, cerrá FocusTracker, volvé a abrirlo y reactivalo. Esto verifica que el Shutdown() es correcto, que los eventos no quedan colgados y que la configuración persiste bien.
Marketplace de plugins
Distribuí tu plugin a todos los usuarios de FocusTracker directamente desde la pantalla de Plugins de la app.
Portal de desarrolladores
El marketplace está en desarrollo activo. En los próximos meses podrás enviar tu plugin para revisión, elegir un modelo de distribución y llegar a todos los usuarios de FocusTracker.
Lo que se viene
- Portal de desarrolladores para subir y gestionar tus plugins.
- Proceso de revisión para garantizar calidad y seguridad antes de publicar.
- Modelos de monetización: gratuito, donación, compra única y suscripción mensual/anual.
- Panel de analytics con instalaciones, ratings y retención.
- Actualizaciones automáticas: los usuarios son notificados cuando publiques una nueva versión.
Mientras tanto
Podés compartir tu .focusplugin directamente — se instala desde Plugins → Importar con un click. Si querés ser de los primeros en publicar cuando el marketplace abra, o tenés preguntas sobre el SDK, escribime a hola@santiagorada.com.