feat: add plugin UI host (sidebar, view container, settings panel) + storage API
- internal/core/storage/api.go — plugin namespace JSON storage (settings/data/cache) - internal/core/storage/api_test.go — 8 tests (write/read, path traversal, atomic) - internal/api/app.go — Wails bindings for storage (Read/WritePluginSettings, Read/WritePluginDataJSON) - main.go — initialize storage service, pass to NewApp - Sidebar.svelte — plugin sidebar items from contributions (filtered by ui.register) - ViewContainer.svelte — plugin view host with degraded status - PluginCard.svelte — Settings button + permission warnings - PluginManager.svelte — settings panel modal with test form - App.svelte — integrated sidebar + view container layout
This commit is contained in:
parent
70d4c75d7e
commit
ca7eb79a40
|
|
@ -1,27 +1,19 @@
|
||||||
<script>
|
<script>
|
||||||
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
|
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
|
||||||
|
import Sidebar from './lib/shell/Sidebar.svelte';
|
||||||
|
import ViewContainer from './lib/shell/ViewContainer.svelte';
|
||||||
|
|
||||||
let view = 'plugin-manager';
|
let currentView = 'plugin-manager';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<nav>
|
<Sidebar />
|
||||||
<h1>Verstak</h1>
|
|
||||||
<div class="nav-items">
|
|
||||||
<button
|
|
||||||
class="nav-item"
|
|
||||||
class:active={view === 'plugin-manager'}
|
|
||||||
on:click={() => view = 'plugin-manager'}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
⚙ Plugin Manager
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section class="content">
|
<section class="content">
|
||||||
{#if view === 'plugin-manager'}
|
{#if currentView === 'plugin-manager'}
|
||||||
<PluginManager />
|
<PluginManager />
|
||||||
|
{:else}
|
||||||
|
<ViewContainer />
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -32,53 +24,10 @@
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
|
||||||
width: 220px;
|
|
||||||
background: #16213e;
|
|
||||||
border-right: 1px solid #0f3460;
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav h1 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #e94560;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #a0a0b8;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
background: #0f3460;
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
background: #e94560;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
display: flex;
|
||||||
overflow-y: auto;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
export let p = {};
|
export let p = {};
|
||||||
export let capabilities = [];
|
export let capabilities = [];
|
||||||
export let permissions = [];
|
export let permissions = [];
|
||||||
export let contributions = {};
|
export let onSettings = () => {};
|
||||||
|
|
||||||
$: m = p.manifest || {};
|
$: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId);
|
||||||
|
$: hasUIPermission = (m.permissions || []).includes('ui.register');
|
||||||
|
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
|
||||||
|
$: hasCommandsPermission = (m.permissions || []).includes('commands.register');
|
||||||
|
|
||||||
$: statusColor = ({
|
$: statusColor = ({
|
||||||
loaded: '#4ecca3',
|
loaded: '#4ecca3',
|
||||||
|
|
@ -160,6 +163,20 @@
|
||||||
{#if p.error}
|
{#if p.error}
|
||||||
<div class="error-box">{p.error}</div>
|
<div class="error-box">{p.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="card-actions">
|
||||||
|
{#if hasSettingsPanel}
|
||||||
|
<button class="btn-settings" on:click={() => onSettings(m.id)} type="button">
|
||||||
|
⚙ Settings
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permission warnings -->
|
||||||
|
{#if !hasUIPermission && (m.contributes && (m.contributes.views || m.contributes.sidebarItems || m.contributes.settingsPanels).length > 0)}
|
||||||
|
<p class="warning">⚠ Plugin has UI contributions but lacks ui.register permission</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -327,4 +344,26 @@
|
||||||
color: #e94560;
|
color: #e94560;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-settings {
|
||||||
|
background: #0f3460;
|
||||||
|
border: 1px solid #1a3a5c;
|
||||||
|
color: #e0e0f0;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-settings:hover {
|
||||||
|
background: #1a3a5c;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error = '';
|
let error = '';
|
||||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
|
let settingsPanel = null;
|
||||||
|
let settingsData = {};
|
||||||
|
let settingsPluginId = '';
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
error = '';
|
error = '';
|
||||||
|
|
@ -49,6 +52,34 @@
|
||||||
$: totalPlugins = plugins.length;
|
$: totalPlugins = plugins.length;
|
||||||
$: totalCaps = capabilities.length;
|
$: totalCaps = capabilities.length;
|
||||||
$: totalPerms = permissions.length;
|
$: totalPerms = permissions.length;
|
||||||
|
|
||||||
|
function openSettings(pluginId) {
|
||||||
|
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId);
|
||||||
|
if (panel) {
|
||||||
|
settingsPanel = panel;
|
||||||
|
settingsPluginId = pluginId;
|
||||||
|
// Load existing settings
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(localStorage.getItem('verstak-settings-' + pluginId) || '{}');
|
||||||
|
settingsData = data;
|
||||||
|
} catch (e) {
|
||||||
|
settingsData = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('verstak-settings-' + settingsPluginId, JSON.stringify(settingsData));
|
||||||
|
// Also try Wails backend
|
||||||
|
const { WritePluginSettings } = require('../../../wailsjs/go/api/App');
|
||||||
|
WritePluginSettings(settingsPluginId, settingsData).then(err => {
|
||||||
|
if (err) console.error('WritePluginSettings:', err);
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('saveSettings:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="plugin-manager">
|
<div class="plugin-manager">
|
||||||
|
|
@ -98,7 +129,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="plugin-list">
|
<div class="plugin-list">
|
||||||
{#each plugins as p}
|
{#each plugins as p}
|
||||||
<PluginCard {p} {capabilities} {permissions} {contributions} />
|
<PluginCard {p} {capabilities} {permissions} {contributions} onSettings={openSettings} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -123,11 +154,47 @@
|
||||||
</table>
|
</table>
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<!-- Settings Panel Modal -->
|
||||||
.plugin-manager { max-width: 900px; }
|
{#if settingsPanel}
|
||||||
|
<div class="modal-overlay" on:click|self={() => settingsPanel = null}>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{settingsPanel.item.title}</h3>
|
||||||
|
<button class="modal-close" on:click={() => settingsPanel = null} type="button">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
|
||||||
|
<p class="settings-hint">Component: <code>{settingsPanel.item.component}</code></p>
|
||||||
|
|
||||||
|
{#if settingsPanel.item.id === 'verstak.platform-test.settings'}
|
||||||
|
<div class="settings-form">
|
||||||
|
<h4>Test Settings</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="test-name">Test Name</label>
|
||||||
|
<input id="test-name" type="text" bind:value={settingsData.testName} placeholder="Enter test name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="test-interval">Test Interval (seconds)</label>
|
||||||
|
<input id="test-interval" type="number" bind:value={settingsData.testInterval} min="1" max="300" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label><input type="checkbox" bind:checked={settingsData.autoRun} /> Auto-run on startup</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn-save" on:click={() => saveSettings()} type="button">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="placeholder">Settings component: {settingsPanel.item.component}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.plugin-manager { max-width: 900px; }
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -235,4 +302,86 @@
|
||||||
color: #a0a0b8;
|
color: #a0a0b8;
|
||||||
border: 1px solid #533483;
|
border: 1px solid #533483;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Modal ── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #16213e;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
.modal-header h3 { margin: 0; color: #e0e0f0; font-size: 1.1rem; }
|
||||||
|
.modal-close {
|
||||||
|
background: none; border: none; color: #a0a0b8;
|
||||||
|
font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
.modal-close:hover { color: #e94560; }
|
||||||
|
.modal-body { padding: 1rem; overflow-y: auto; }
|
||||||
|
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
|
||||||
|
.settings-hint code { color: #4ecca3; }
|
||||||
|
|
||||||
|
/* ── Settings Form ── */
|
||||||
|
.settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.settings-form h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #e0e0f0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.form-row label {
|
||||||
|
color: #a0a0b8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.form-row input[type="text"],
|
||||||
|
.form-row input[type="number"] {
|
||||||
|
background: #0f3460;
|
||||||
|
border: 1px solid #1a3a5c;
|
||||||
|
color: #e0e0f0;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form-row input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4ecca3;
|
||||||
|
}
|
||||||
|
.btn-save {
|
||||||
|
background: #4ecca3;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-save:hover {
|
||||||
|
background: #3dbb92;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
|
let sidebarItems = [];
|
||||||
|
let activeView = '';
|
||||||
|
let plugins = [];
|
||||||
|
let contributions = { sidebarItems: [], views: [], commands: [], settingsPanels: [] };
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [contribs, pluginList] = await Promise.all([
|
||||||
|
App.GetContributions(),
|
||||||
|
App.GetPlugins(),
|
||||||
|
]);
|
||||||
|
contributions = contribs;
|
||||||
|
plugins = pluginList;
|
||||||
|
const pluginMap = new Map(pluginList.map(p => [p.manifest.id, p]));
|
||||||
|
sidebarItems = (contribs.sidebarItems || []).filter(item => {
|
||||||
|
const plugin = pluginMap.get(item.pluginId);
|
||||||
|
return plugin && plugin.manifest.permissions.includes('ui.register');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Sidebar] load error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openView(viewId) {
|
||||||
|
activeView = viewId;
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId } }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="sidebar">
|
||||||
|
{#each sidebarItems as item}
|
||||||
|
<button
|
||||||
|
class="sidebar-item"
|
||||||
|
class:active={activeView === item.item.view}
|
||||||
|
on:click={() => openView(item.item.view)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if item.item.icon}<span class="icon">{item.item.icon}</span>{/if}
|
||||||
|
<span class="label">{item.item.title}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar {
|
||||||
|
width: 200px;
|
||||||
|
background: #16213e;
|
||||||
|
border-right: 1px solid #0f3460;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #a0a0b0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.sidebar-item:hover { background: #0f3460; color: #e0e0f0; }
|
||||||
|
.sidebar-item.active { background: #0f3460; color: #4ecca3; }
|
||||||
|
.icon { font-size: 1.1rem; }
|
||||||
|
.label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
|
let views = [];
|
||||||
|
let activeView = '';
|
||||||
|
let pluginStates = {};
|
||||||
|
let plugins = [];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [contribs, pluginList] = await Promise.all([
|
||||||
|
App.GetContributions(),
|
||||||
|
App.GetPlugins(),
|
||||||
|
]);
|
||||||
|
views = contribs.views || [];
|
||||||
|
plugins = pluginList;
|
||||||
|
for (const p of pluginList) {
|
||||||
|
pluginStates[p.manifest.id] = p.status;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ViewContainer] load error:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('verstak:open-view', (e) => {
|
||||||
|
activeView = e.detail.viewId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getViewStatus(view) {
|
||||||
|
const status = pluginStates[view.pluginId];
|
||||||
|
if (status === 'failed' || status === 'incompatible') return 'error';
|
||||||
|
if (status === 'degraded') return 'degraded';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="view-container">
|
||||||
|
{#if activeView}
|
||||||
|
{#each views.filter(v => v.item.id === activeView) as view}
|
||||||
|
<div class="view" class:degraded={getViewStatus(view) === 'degraded'}>
|
||||||
|
<div class="view-header">
|
||||||
|
<span class="view-icon">{view.item.icon || '📦'}</span>
|
||||||
|
<h2>{view.item.title}</h2>
|
||||||
|
{#if getViewStatus(view) === 'degraded'}
|
||||||
|
<span class="badge degraded">degraded</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="view-content">
|
||||||
|
<div class="plugin-view-host" data-view-id={view.item.id} data-component={view.item.component}>
|
||||||
|
<p class="placeholder">
|
||||||
|
Plugin view: <strong>{view.item.component}</strong>
|
||||||
|
<br />
|
||||||
|
<span class="sub">from {view.pluginId}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">View "{activeView}" not found in contributions</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="empty">
|
||||||
|
<p>Select an item from the sidebar</p>
|
||||||
|
<p class="sub">Plugin views will appear here</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.view-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
.view {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.view.degraded {
|
||||||
|
border-left: 3px solid #ffc857;
|
||||||
|
}
|
||||||
|
.view-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #16213e;
|
||||||
|
}
|
||||||
|
.view-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #e0e0f0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.view-icon { font-size: 1.3rem; }
|
||||||
|
.view-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.plugin-view-host {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.placeholder strong { color: #4ecca3; }
|
||||||
|
.placeholder .sub { font-size: 0.85rem; color: #555; }
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #555;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
|
||||||
|
.badge {
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge.degraded { background: #ffc857; color: #1a1a2e; }
|
||||||
|
</style>
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/permissions"
|
"github.com/verstak/verstak-desktop/internal/core/permissions"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ type App struct {
|
||||||
eventBus *events.Bus
|
eventBus *events.Bus
|
||||||
plugins []plugin.Plugin
|
plugins []plugin.Plugin
|
||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
|
storage *storage.Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App instance.
|
// NewApp creates a new App instance.
|
||||||
|
|
@ -34,6 +36,7 @@ func NewApp(
|
||||||
bus *events.Bus,
|
bus *events.Bus,
|
||||||
plugins []plugin.Plugin,
|
plugins []plugin.Plugin,
|
||||||
vaultService *vault.Vault,
|
vaultService *vault.Vault,
|
||||||
|
storageService *storage.Storage,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
return &App{
|
||||||
capRegistry: capReg,
|
capRegistry: capReg,
|
||||||
|
|
@ -42,6 +45,7 @@ func NewApp(
|
||||||
eventBus: bus,
|
eventBus: bus,
|
||||||
plugins: plugins,
|
plugins: plugins,
|
||||||
vault: vaultService,
|
vault: vaultService,
|
||||||
|
storage: storageService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,6 +252,83 @@ func (a *App) CloseVault() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Storage API ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ReadPluginSettings returns all settings for a plugin.
|
||||||
|
func (a *App) ReadPluginSettings(pluginID string) map[string]interface{} {
|
||||||
|
if a.storage == nil {
|
||||||
|
return make(map[string]interface{})
|
||||||
|
}
|
||||||
|
data, err := a.storage.ReadPluginSettings(pluginID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[api] ReadPluginSettings(%s): %v", pluginID, err)
|
||||||
|
return make(map[string]interface{})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePluginSettings writes all settings for a plugin.
|
||||||
|
func (a *App) WritePluginSettings(pluginID string, data map[string]interface{}) string {
|
||||||
|
if a.storage == nil {
|
||||||
|
return "storage not initialized"
|
||||||
|
}
|
||||||
|
if err := a.storage.WritePluginSettings(pluginID, data); err != nil {
|
||||||
|
log.Printf("[api] WritePluginSettings(%s): %v", pluginID, err)
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPluginSetting returns a single setting value.
|
||||||
|
func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
|
||||||
|
if a.storage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
val, err := a.storage.ReadPluginSetting(pluginID, key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[api] ReadPluginSetting(%s, %s): %v", pluginID, key, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePluginSetting writes a single setting value.
|
||||||
|
func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string {
|
||||||
|
if a.storage == nil {
|
||||||
|
return "storage not initialized"
|
||||||
|
}
|
||||||
|
if err := a.storage.WritePluginSetting(pluginID, key, value); err != nil {
|
||||||
|
log.Printf("[api] WritePluginSetting(%s, %s): %v", pluginID, key, err)
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPluginDataJSON reads a named JSON data file for a plugin.
|
||||||
|
func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
|
||||||
|
if a.storage == nil {
|
||||||
|
return make(map[string]interface{})
|
||||||
|
}
|
||||||
|
data, err := a.storage.ReadPluginDataJSON(pluginID, name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[api] ReadPluginDataJSON(%s, %s): %v", pluginID, name, err)
|
||||||
|
return make(map[string]interface{})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePluginDataJSON writes a named JSON data file for a plugin.
|
||||||
|
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
|
||||||
|
if a.storage == nil {
|
||||||
|
return "storage not initialized"
|
||||||
|
}
|
||||||
|
if err := a.storage.WritePluginDataJSON(pluginID, name, data); err != nil {
|
||||||
|
log.Printf("[api] WritePluginDataJSON(%s, %s): %v", pluginID, name, err)
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ContributionSummary aggregates all contribution types for the frontend.
|
// ContributionSummary aggregates all contribution types for the frontend.
|
||||||
type ContributionSummary struct {
|
type ContributionSummary struct {
|
||||||
Views []contribution.ContributionView `json:"views"`
|
Views []contribution.ContributionView `json:"views"`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
// Package storage provides a safe, namespace-isolated JSON storage API for plugins.
|
||||||
|
// All data is stored within the vault's .verstak directory, scoped per plugin.
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storage provides plugin-scoped JSON storage (settings, data, cache).
|
||||||
|
type Storage struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
vault *vault.Vault
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Storage instance backed by the given vault.
|
||||||
|
func New(v *vault.Vault) *Storage {
|
||||||
|
return &Storage{vault: v}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plugin ID validation ─────────────────────────────────
|
||||||
|
|
||||||
|
func validatePluginID(pluginID string) error {
|
||||||
|
if pluginID == "" {
|
||||||
|
return fmt.Errorf("plugin ID is empty")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(pluginID, `/\`) {
|
||||||
|
return fmt.Errorf("plugin ID %q contains path separators", pluginID)
|
||||||
|
}
|
||||||
|
if pluginID == "." || pluginID == ".." {
|
||||||
|
return fmt.Errorf("plugin ID %q is a path traversal reference", pluginID)
|
||||||
|
}
|
||||||
|
cleaned := filepath.Clean(pluginID)
|
||||||
|
if cleaned != pluginID {
|
||||||
|
return fmt.Errorf("plugin ID %q contains path traversal", pluginID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Atomic write helper ──────────────────────────────────
|
||||||
|
|
||||||
|
func atomicWrite(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create dir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
tmpFile := filepath.Join(dir, fmt.Sprintf(".tmp.%d", time.Now().UnixNano()))
|
||||||
|
if err := os.WriteFile(tmpFile, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpFile, path); err != nil {
|
||||||
|
os.Remove(tmpFile) // best-effort cleanup
|
||||||
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings API ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// ReadPluginSettings reads all settings for a plugin.
|
||||||
|
// Returns empty map if settings.json does not exist.
|
||||||
|
func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) {
|
||||||
|
if err := validatePluginID(pluginID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := s.vault.GetPluginSettingsPath(pluginID)
|
||||||
|
path := filepath.Join(dir, "settings.json")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read settings for plugin %s: %w", pluginID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("corrupt settings.json for plugin %s: %w", pluginID, err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePluginSettings writes all settings for a plugin atomically.
|
||||||
|
func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error {
|
||||||
|
if err := validatePluginID(pluginID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := s.vault.GetPluginSettingsPath(pluginID)
|
||||||
|
path := filepath.Join(dir, "settings.json")
|
||||||
|
|
||||||
|
encoded, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err)
|
||||||
|
}
|
||||||
|
return atomicWrite(path, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPluginSetting reads a single setting key.
|
||||||
|
func (s *Storage) ReadPluginSetting(pluginID, key string) (interface{}, error) {
|
||||||
|
settings, err := s.ReadPluginSettings(pluginID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
val, ok := settings[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePluginSetting writes a single setting key.
|
||||||
|
func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error {
|
||||||
|
settings, err := s.ReadPluginSettings(pluginID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings[key] = value
|
||||||
|
return s.WritePluginSettings(pluginID, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data JSON API ────────────────────────────────────────
|
||||||
|
|
||||||
|
// ReadPluginDataJSON reads a named JSON data file for a plugin.
|
||||||
|
func (s *Storage) ReadPluginDataJSON(pluginID, name string) (map[string]interface{}, error) {
|
||||||
|
if err := validatePluginID(pluginID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("data name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := s.vault.GetPluginDataPath(pluginID)
|
||||||
|
path := filepath.Join(dir, name+".json")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read data %s for plugin %s: %w", name, pluginID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("corrupt data file %s.json for plugin %s: %w", name, pluginID, err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePluginDataJSON writes a named JSON data file for a plugin atomically.
|
||||||
|
func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) error {
|
||||||
|
if err := validatePluginID(pluginID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("data name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := s.vault.GetPluginDataPath(pluginID)
|
||||||
|
path := filepath.Join(dir, name+".json")
|
||||||
|
|
||||||
|
encoded, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal data %s for plugin %s: %w", name, pluginID, err)
|
||||||
|
}
|
||||||
|
return atomicWrite(path, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cache JSON API ───────────────────────────────────────
|
||||||
|
|
||||||
|
// ReadPluginCacheJSON reads a named JSON cache file for a plugin.
|
||||||
|
func (s *Storage) ReadPluginCacheJSON(pluginID, name string) (map[string]interface{}, error) {
|
||||||
|
if err := validatePluginID(pluginID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("cache name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := s.vault.GetPluginCachePath(pluginID)
|
||||||
|
path := filepath.Join(dir, name+".json")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read cache %s for plugin %s: %w", name, pluginID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("corrupt cache file %s.json for plugin %s: %w", name, pluginID, err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePluginCacheJSON writes a named JSON cache file for a plugin atomically.
|
||||||
|
func (s *Storage) WritePluginCacheJSON(pluginID, name string, data map[string]interface{}) error {
|
||||||
|
if err := validatePluginID(pluginID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("cache name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := s.vault.GetPluginCachePath(pluginID)
|
||||||
|
path := filepath.Join(dir, name+".json")
|
||||||
|
|
||||||
|
encoded, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal cache %s for plugin %s: %w", name, pluginID, err)
|
||||||
|
}
|
||||||
|
return atomicWrite(path, encoded)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestVault creates a vault in a temp directory for testing.
|
||||||
|
func newTestVault(t *testing.T) (*vault.Vault, string) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
v := vault.NewVault(nil)
|
||||||
|
if err := v.CreateVault(tmpDir); err != nil {
|
||||||
|
t.Fatalf("failed to create test vault: %v", err)
|
||||||
|
}
|
||||||
|
return v, tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestStorage(t *testing.T) (*Storage, string) {
|
||||||
|
t.Helper()
|
||||||
|
v, dir := newTestVault(t)
|
||||||
|
return New(v), dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings tests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWriteReadPluginSettings(t *testing.T) {
|
||||||
|
s, _ := newTestStorage(t)
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"theme": "dark",
|
||||||
|
"lang": "en",
|
||||||
|
"count": float64(42),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.WritePluginSettings("my-plugin", data); err != nil {
|
||||||
|
t.Fatalf("WritePluginSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.ReadPluginSettings("my-plugin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["theme"] != "dark" {
|
||||||
|
t.Errorf("theme = %v, want dark", got["theme"])
|
||||||
|
}
|
||||||
|
if got["lang"] != "en" {
|
||||||
|
t.Errorf("lang = %v, want en", got["lang"])
|
||||||
|
}
|
||||||
|
if got["count"] != float64(42) {
|
||||||
|
t.Errorf("count = %v, want 42", got["count"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPluginSettings_NotFound(t *testing.T) {
|
||||||
|
s, _ := newTestStorage(t)
|
||||||
|
|
||||||
|
got, err := s.ReadPluginSettings("unknown-plugin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginSettings: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("expected empty map, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPluginSettings_Corrupt(t *testing.T) {
|
||||||
|
s, dir := newTestStorage(t)
|
||||||
|
|
||||||
|
// Write corrupt JSON into the settings file
|
||||||
|
settingsDir := filepath.Join(dir, "VerstakVault", ".verstak", "plugin-settings", "bad-plugin")
|
||||||
|
os.MkdirAll(settingsDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(settingsDir, "settings.json"), []byte("{not json!!"), 0o644)
|
||||||
|
|
||||||
|
_, err := s.ReadPluginSettings("bad-plugin")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for corrupt settings.json, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWritePluginSetting_SingleKey(t *testing.T) {
|
||||||
|
s, _ := newTestStorage(t)
|
||||||
|
|
||||||
|
// Write a single key
|
||||||
|
if err := s.WritePluginSetting("my-plugin", "key1", "value1"); err != nil {
|
||||||
|
t.Fatalf("WritePluginSetting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read it back
|
||||||
|
val, err := s.ReadPluginSetting("my-plugin", "key1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginSetting: %v", err)
|
||||||
|
}
|
||||||
|
if val != "value1" {
|
||||||
|
t.Errorf("key1 = %v, want value1", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write another key, first should be preserved
|
||||||
|
if err := s.WritePluginSetting("my-plugin", "key2", float64(99)); err != nil {
|
||||||
|
t.Fatalf("WritePluginSetting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := s.ReadPluginSettings("my-plugin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginSettings: %v", err)
|
||||||
|
}
|
||||||
|
if settings["key1"] != "value1" {
|
||||||
|
t.Errorf("key1 after second write = %v, want value1", settings["key1"])
|
||||||
|
}
|
||||||
|
if settings["key2"] != float64(99) {
|
||||||
|
t.Errorf("key2 = %v, want 99", settings["key2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading a missing key returns nil
|
||||||
|
val, err = s.ReadPluginSetting("my-plugin", "missing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginSetting: %v", err)
|
||||||
|
}
|
||||||
|
if val != nil {
|
||||||
|
t.Errorf("missing key = %v, want nil", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data JSON tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestPluginDataJSON_WriteRead(t *testing.T) {
|
||||||
|
s, _ := newTestStorage(t)
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"items": []interface{}{"a", "b", "c"},
|
||||||
|
"meta": map[string]interface{}{"version": float64(1)},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.WritePluginDataJSON("data-plugin", "mydata", data); err != nil {
|
||||||
|
t.Fatalf("WritePluginDataJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.ReadPluginDataJSON("data-plugin", "mydata")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginDataJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, ok := got["items"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("items is not []interface{}, it's %T", got["items"])
|
||||||
|
}
|
||||||
|
if len(items) != 3 {
|
||||||
|
t.Errorf("items len = %d, want 3", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure separate names don't collide
|
||||||
|
got2, err := s.ReadPluginDataJSON("data-plugin", "other")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginDataJSON other: %v", err)
|
||||||
|
}
|
||||||
|
if len(got2) != 0 {
|
||||||
|
t.Errorf("expected empty map for other, got %v", got2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cache JSON tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestPluginCacheJSON_WriteRead(t *testing.T) {
|
||||||
|
s, _ := newTestStorage(t)
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"lastSync": "2025-01-01T00:00:00Z",
|
||||||
|
"hitRate": 0.95,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.WritePluginCacheJSON("cache-plugin", "sync-state", data); err != nil {
|
||||||
|
t.Fatalf("WritePluginCacheJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.ReadPluginCacheJSON("cache-plugin", "sync-state")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginCacheJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["lastSync"] != "2025-01-01T00:00:00Z" {
|
||||||
|
t.Errorf("lastSync = %v", got["lastSync"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty read for missing cache
|
||||||
|
got2, err := s.ReadPluginCacheJSON("cache-plugin", "nope")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadPluginCacheJSON nope: %v", err)
|
||||||
|
}
|
||||||
|
if len(got2) != 0 {
|
||||||
|
t.Errorf("expected empty map, got %v", got2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Path traversal tests ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestPathTraversal_Blocked(t *testing.T) {
|
||||||
|
s, _ := newTestStorage(t)
|
||||||
|
|
||||||
|
traversalIDs := []string{
|
||||||
|
"..",
|
||||||
|
"../evil",
|
||||||
|
"foo/../../bar",
|
||||||
|
"/absolute",
|
||||||
|
`backslash\traverse`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range traversalIDs {
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
err := s.WritePluginSettings(id, map[string]interface{}{"x": 1})
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("WritePluginSettings(%q): expected error, got nil", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.ReadPluginSettings(id)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ReadPluginSettings(%q): expected error, got nil", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty pluginID should also be rejected
|
||||||
|
err := s.WritePluginSettings("", map[string]interface{}{})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("WritePluginSettings(\"\"): expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Atomic write tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAtomicWrite(t *testing.T) {
|
||||||
|
s, dir := newTestStorage(t)
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"key": "value",
|
||||||
|
"n": float64(123),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.WritePluginSettings("atomic-plugin", data); err != nil {
|
||||||
|
t.Fatalf("WritePluginSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no .tmp files remain in the settings directory
|
||||||
|
settingsDir := filepath.Join(dir, "VerstakVault", ".verstak", "plugin-settings", "atomic-plugin")
|
||||||
|
entries, err := os.ReadDir(settingsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if filepath.Ext(e.Name()) == ".tmp" || (len(e.Name()) > 4 && e.Name()[:4] == ".tmp") {
|
||||||
|
t.Errorf("leftover temp file found: %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
main.go
4
main.go
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/permissions"
|
"github.com/verstak/verstak-desktop/internal/core/permissions"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -155,7 +156,8 @@ func main() {
|
||||||
loaded, degraded, failed, vaultService.GetVaultStatus())
|
loaded, degraded, failed, vaultService.GetVaultStatus())
|
||||||
|
|
||||||
// Create the App struct
|
// Create the App struct
|
||||||
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService)
|
storageService := storage.New(vaultService)
|
||||||
|
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService)
|
||||||
|
|
||||||
// ─── Wails App ───────────────────────────────────────────
|
// ─── Wails App ───────────────────────────────────────────
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue