845 lines
40 KiB
Markdown
845 lines
40 KiB
Markdown
# Plugin Runtime
|
||
|
||
Описание системы плагинов Verstak Desktop: discovery, lifecycle, capabilities, states.
|
||
|
||
## Plugin Discovery
|
||
|
||
### Discovery Directories
|
||
|
||
Plugins ищутся через единый resolver `internal/core/plugin.ResolveDiscoveryDirs`.
|
||
Порядок приоритета:
|
||
|
||
| Путь | Назначение | Коммитится |
|
||
|---|---|---|
|
||
| `VERSTAK_PLUGIN_DIR` | Override для тестов/dev; можно передать несколько путей через OS path separator | Нет |
|
||
| `./plugins/` | Dev plugins относительно текущей рабочей директории/repo | Нет (`.gitignore`) |
|
||
| `<binary-dir>/plugins/` | Packaged plugins рядом с desktop binary | Зависит от дистрибутива |
|
||
| `~/.config/verstak/plugins/` | User-installed plugins | Нет (user home) |
|
||
|
||
Resolver нормализует пути, удаляет дубликаты и передает discovery только канонический
|
||
список директорий. Отсутствующие директории просто пропускаются на этапе scanning.
|
||
|
||
Discovery сканирует **все** resolved директории в указанном порядке. Если один и тот
|
||
же `plugin.id` найден несколько раз, применяется правило **first plugin wins**:
|
||
первый найденный plugin загружается, последующие plugins с тем же id пропускаются.
|
||
Конфликт логируется и возвращается как discovery warning с двумя путями: путь
|
||
пропущенного duplicate и путь уже загруженного winner.
|
||
|
||
### ./plugins/ как Dev/Install Target
|
||
|
||
Директория `./plugins/` от текущей рабочей директории используется как:
|
||
|
||
- **Dev target** — `install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`.
|
||
- **Local override** — при запуске desktop из repo позволяет быстро проверять packaged bundles.
|
||
|
||
В packaged-сборке bundled plugins должны лежать в `plugins/` рядом с executable.
|
||
Для тестов и локальных сценариев можно задать `VERSTAK_PLUGIN_DIR=/path/to/plugins`.
|
||
|
||
Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`.
|
||
|
||
### Discovery Process
|
||
|
||
1. `DiscoverPlugins(dirs []string)` сканирует каждую директорию.
|
||
2. Для каждой поддиректории читает `plugin.json`.
|
||
3. `ValidateManifest()` проверяет: `schemaVersion == 1`, `id`, `name`, `version`, `apiVersion`, минимум 1 `provides`, минимум 1 `permissions`.
|
||
4. Дубликаты `id` отбрасываются с warning.
|
||
|
||
Подробнее о формате manifest см. [Plugin Manifest Format](#plugin-manifest-format).
|
||
|
||
## Plugin Lifecycle States
|
||
|
||
```
|
||
discovered
|
||
│
|
||
├─ disabled — plugin.json найден, но plugin отключён
|
||
│
|
||
├─ loading — plugin начинает загрузку
|
||
│
|
||
├─ loaded — все required и optional capabilities разрешены
|
||
│
|
||
├─ degraded — required capabilities разрешены, но не хватает optional
|
||
│
|
||
├─ missing-required-capability — не хватает хотя бы одной required capability
|
||
│
|
||
├─ failed — ошибка при регистрации capabilities (дубликат, panic)
|
||
│
|
||
├─ incompatible — schemaVersion или apiVersion не поддерживаются
|
||
│
|
||
└─ (Скрыто) — discovered используется как промежуточный статус в discovery
|
||
```
|
||
|
||
### Определения статусов
|
||
|
||
| Статус | Условие | Поведение |
|
||
|---|---|---|
|
||
| `discovered` | plugin.json прочитан и валиден | Промежуточный, до capability resolution |
|
||
| `disabled` | Plugin отключён пользователем | Не загружается |
|
||
| `loaded` | Все capabilities разрешены | Полная функциональность |
|
||
| `degraded` | Required OK, но не хватает optional | Работает, часть функций недоступна |
|
||
| `missing-required-capability` | Не хватает required capability | Не загружается, показать ошибку |
|
||
| `failed` | Ошибка регистрации capabilities | Не загружается |
|
||
| `incompatible` | Неподдерживаемая schemaVersion/apiVersion | Не загружается |
|
||
|
||
## Required / Optional Capabilities
|
||
|
||
### Правило
|
||
|
||
- **`requires`** — жёсткая зависимость. Если ни один plugin не предоставляет требуемый capability, плагин получает `missing-required-capability` и **не загружается**.
|
||
- **`optionalRequires`** — мягкая зависимость. Если capability нет, плагин переходит в `degraded`, но **продолжает работать**.
|
||
|
||
### Регистрация core capabilities
|
||
|
||
Core capabilities регистрируются в `main.go` ДО plugin discovery:
|
||
|
||
```go
|
||
coreCaps := []string{
|
||
"verstak/core/plugin-manager/v1",
|
||
"verstak/core/capability-registry/v1",
|
||
"verstak/core/contribution-registry/v1",
|
||
"verstak/core/permissions/v1",
|
||
"verstak/core/events/v1",
|
||
"verstak/core/files/v1",
|
||
"verstak/core/workbench/v1",
|
||
}
|
||
capRegistry.Register("verstak-desktop", coreCaps)
|
||
|
||
// Vault capability — регистрируется отдельно после vault initialization
|
||
capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
|
||
```
|
||
|
||
Это гарантирует, что любой plugin с `requires: ["verstak/core/plugin-manager/v1"]` будет загружен.
|
||
|
||
### Plugin capability resolution
|
||
|
||
```
|
||
foreach plugin:
|
||
1. зарегистрировать plugin.provides в capability registry
|
||
2. проверить plugin.requires — если есть missing → missing-required-capability
|
||
3. проверить plugin.optionalRequires — если есть missing → degraded
|
||
4. иначе → loaded
|
||
5. зарегистрировать plugin.contributes в contribution registry
|
||
```
|
||
|
||
### Capability конфликт
|
||
|
||
Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации — ошибка и статус `failed` для второго плагина.
|
||
|
||
## Plugin Manifest Format
|
||
|
||
Файл `plugin.json` в корне директории плагина.
|
||
|
||
### Обязательные поля
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `schemaVersion` | int | Должен быть `1` |
|
||
| `id` | string | Уникальный идентификатор (regex: `[a-zA-Z0-9.-]+`) |
|
||
| `name` | string | Человекочитаемое имя |
|
||
| `version` | string | Semver (напр. `"0.1.0"`) |
|
||
| `apiVersion` | string | API версии plugin |
|
||
| `provides` | string[] | Список capabilities (мин. 1) |
|
||
| `permissions` | string[] | Список запрашиваемых permissions (мин. 1) |
|
||
|
||
### Опциональные поля
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `description` | string | Описание плагина |
|
||
| `source` | string | `"official"`, `"local"`, `"third-party"` |
|
||
| `icon` | string | Иконка (emoji или имя) |
|
||
| `requires` | string[] | Жёзкие capability-зависимости |
|
||
| `optionalRequires` | string[] | Мягкие capability-зависимости |
|
||
| `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` |
|
||
| `backend` | object | `{ "type": "go", "entry": { "linux-amd64": "...", ... }, "healthCheck": {...} }` |
|
||
| `migrations` | object | `{ "path": "migrations/" }` |
|
||
| `contributes` | object | UI contributions (см. ниже) |
|
||
| `sync` | object | `{ "namespaces": [...], "participate": bool }` |
|
||
|
||
### Пример
|
||
|
||
```json
|
||
{
|
||
"schemaVersion": 1,
|
||
"id": "verstak.platform-test",
|
||
"name": "Platform Test",
|
||
"version": "0.1.0",
|
||
"apiVersion": "0.1.0",
|
||
"provides": ["verstak/platform-test/v1"],
|
||
"requires": ["verstak/core/plugin-manager/v1"],
|
||
"optionalRequires": ["verstak/core/vault/v1", "verstak/core/sync/v1"],
|
||
"permissions": ["vault.read", "events.publish", "ui.register", "workbench.open"],
|
||
"frontend": { "entry": "frontend/dist/index.js" },
|
||
"contributes": {
|
||
"views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }],
|
||
"commands": [{ "id": "my.cmd", "title": "Run", "handler": "run" }]
|
||
}
|
||
}
|
||
```
|
||
|
||
## Contribution Points
|
||
|
||
Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`.
|
||
|
||
### Реализованные contribution points (Milestone 5a)
|
||
|
||
| Тип | Поле manifest | Описание | Frontend host |
|
||
|---|---|---|---|
|
||
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
|
||
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
|
||
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
|
||
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
|
||
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
|
||
|
||
### Планируемые contribution points
|
||
|
||
| Тип | Поле manifest | Статус |
|
||
|---|---|---|
|
||
| Действия над файлами | `fileActions` | Registry готов, UI не реализован |
|
||
| Действия над заметками | `noteActions` | Registry готов, UI не реализован |
|
||
| Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован |
|
||
| Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован |
|
||
| Провайдеры активности | `activityProviders` | Registry готов, UI не реализован |
|
||
| Элементы status bar | `statusBarItems` | Registry готов, UI не реализован |
|
||
|
||
### Структура contribution points в manifest
|
||
|
||
```json
|
||
{
|
||
"contributes": {
|
||
"sidebarItems": [
|
||
{
|
||
"id": "mypanel.sidebar",
|
||
"title": "My Panel",
|
||
"icon": "📌",
|
||
"view": "mypanel.view",
|
||
"position": 100
|
||
}
|
||
],
|
||
"views": [
|
||
{
|
||
"id": "mypanel.view",
|
||
"title": "My Panel View",
|
||
"icon": "📌",
|
||
"component": "MyPanelComponent"
|
||
}
|
||
],
|
||
"settingsPanels": [
|
||
{
|
||
"id": "mypanel.settings",
|
||
"title": "My Settings",
|
||
"component": "MySettingsPanel"
|
||
}
|
||
],
|
||
"commands": [
|
||
{
|
||
"id": "mypanel.cmd",
|
||
"title": "Do Something",
|
||
"icon": "⚡",
|
||
"handler": "doSomething"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### Contribution lifecycle
|
||
|
||
1. Plugin `Register(pluginID, contributions)` — все contributions регистрируются
|
||
2. `Unregister(pluginID)` — удаляет все contributions указанного plugin
|
||
3. Reload: `Unregister → Register` (предотвращает дублирование)
|
||
4. Disable plugin → `Unregister` (contributions исчезают из UI)
|
||
5. Enable plugin → `Register` при следующем Reload
|
||
6. Registry idempotent: Register удаляет старые записи перед добавлением новых
|
||
|
||
## Bundled Frontend Plugin API
|
||
|
||
Bundled frontend plugins получают API от host через `createPluginAPI(pluginId)`.
|
||
Обычный plugin code не передает `pluginId` в методы API: scope закрепляется в
|
||
host при mount компонента. Это защищает нормальный cooperative path от случайного
|
||
доступа к чужому namespace.
|
||
|
||
Текущая модель безопасности честно ограничена:
|
||
|
||
- bundled frontend plugins исполняются в общем JS-контексте приложения;
|
||
- проверки permissions/capabilities сейчас являются contract/policy checks, а не
|
||
полноценной security boundary;
|
||
- malicious JS в общем контексте теоретически может обойти frontend wrapper;
|
||
- настоящая изоляция будет только после отдельного sidecar/sandbox milestone.
|
||
|
||
## Workbench Open/Edit Routing
|
||
|
||
Files and Notes plugins do not import or embed a concrete editor plugin. They
|
||
call `api.workbench.openResource(request)` or `api.workbench.editResource(request)`.
|
||
The backend requires the source plugin to be enabled, loaded/degraded, and to
|
||
declare `workbench.open`. This is a policy/contract check, not a security
|
||
boundary.
|
||
|
||
`OpenResourceRequest`:
|
||
|
||
```ts
|
||
type OpenResourceRequest = {
|
||
kind: "vault-file";
|
||
path: string;
|
||
mode?: "view" | "edit";
|
||
mime?: string;
|
||
extension?: string;
|
||
context?: {
|
||
sourcePluginId?: string;
|
||
sourceView?: "files" | "notes" | string;
|
||
isInsideNotesFolder?: boolean;
|
||
notesScopePath?: string;
|
||
notesMode?: boolean;
|
||
};
|
||
};
|
||
```
|
||
|
||
Routing contexts are fixed as `generic-text`, `generic-markdown`, and
|
||
`notes-markdown`. `.md`/`.markdown` inside canonical `Notes/` folders uses
|
||
`notes-markdown`; markdown outside Notes uses `generic-markdown`; ordinary text
|
||
uses `generic-text`. Milestone 6b derives context from request fields; future
|
||
Files/Notes integrations can centralize canonical Notes folder auto-detection in
|
||
the Workbench helper.
|
||
|
||
`contributes.openProviders` extends the existing contribution registry:
|
||
|
||
```json
|
||
{
|
||
"contributes": {
|
||
"openProviders": [
|
||
{
|
||
"id": "verstak.platform-test.markdown-diagnostic",
|
||
"title": "Platform Test Markdown Diagnostic",
|
||
"priority": 100,
|
||
"component": "MarkdownDiagnosticProvider",
|
||
"supports": [
|
||
{
|
||
"kind": "vault-file",
|
||
"extensions": [".md", ".markdown"],
|
||
"contexts": ["generic-markdown", "notes-markdown"]
|
||
},
|
||
{
|
||
"kind": "vault-file",
|
||
"mime": ["text/plain"],
|
||
"extensions": [".txt", ".log"],
|
||
"contexts": ["generic-text"]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
Selection uses enabled loaded/degraded provider plugins, resource kind,
|
||
extension/mime, context, user preference, priority, then deterministic
|
||
`pluginId/providerId` fallback. If nothing matches, Workbench returns
|
||
`status: "no-provider"` and shows the fallback view instead of a core editor.
|
||
|
||
Disabled/failed/missing-required-capability plugins are excluded from provider
|
||
selection at request time by `activeOpenProviders()`. Their contributions may
|
||
remain in the registry until the next `ReloadPlugins()` cycle, but they never
|
||
match during routing.
|
||
|
||
Draft app-global preferences are `defaultTextEditorProvider`,
|
||
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
|
||
Vault-scoped and per-extension overrides are deferred.
|
||
|
||
### Default Editor Plugin
|
||
|
||
The official `verstak.default-editor` plugin (`verstak-official-plugins/plugins/default-editor/`)
|
||
provides openProviders for text, generic markdown, and notes-context markdown files.
|
||
It uses `api.files.readText` / `api.files.writeText` for file I/O and mounts through
|
||
the standard `PluginBundleHost` / provider host mechanism. Core does not import or
|
||
reference this plugin directly.
|
||
|
||
Provider plugins may have no sidebar item — openProviders are contribution points
|
||
for workbench routing, not navigation. Plugin Manager displays openProviders in the
|
||
contributions summary.
|
||
|
||
### API methods
|
||
|
||
`settings`
|
||
|
||
- `settings.read()` — читает весь settings namespace текущего plugin.
|
||
- `settings.read(key)` — читает один ключ.
|
||
- `settings.write(key, value)` — обновляет один ключ и пишет namespace обратно.
|
||
- `settings.writeAll(settings)` — заменяет settings namespace.
|
||
- Backend требует plugin exists, enabled, status `loaded`/`degraded` и permission
|
||
`storage.namespace`.
|
||
|
||
`capabilities`
|
||
|
||
- `capabilities.list()` — возвращает текущий capability registry.
|
||
- `capabilities.get(name)` — возвращает `{ available, name, pluginId, status }`.
|
||
- `capabilities.has(name)` — boolean wrapper над `get`.
|
||
- Backend требует, чтобы plugin был enabled/loaded и декларировал dependency на
|
||
`verstak/core/capability-registry/v1` в `requires` или `optionalRequires`.
|
||
|
||
`commands`
|
||
|
||
- `commands.register(commandId, handler)` — регистрирует bundled frontend handler.
|
||
Возвращает `Promise<unsubscribe>`.
|
||
- `commands.execute(commandId, args)` — backend сначала проверяет plugin status,
|
||
permission `commands.register` и что command объявлен в `contributes.commands`
|
||
именно этим plugin. Затем frontend registry вызывает зарегистрированный handler.
|
||
- Если command объявлен в manifest, но handler не зарегистрирован, API возвращает
|
||
понятную ошибку `declared-but-unhandled`.
|
||
- Handler registry очищается при component unmount, reload/disable flow и
|
||
`api.dispose()`.
|
||
|
||
`events`
|
||
|
||
- `events.subscribe(eventName, handler)` — frontend-local subscription с backend
|
||
validation permission `events.subscribe`. Возвращает `Promise<unsubscribe>`.
|
||
- `events.publish(eventName, payload)` — backend проверяет `events.publish`, затем
|
||
событие dispatch'ится в bundled frontend event bus.
|
||
- Handler получает envelope `{ name, pluginId, payload, timestamp }`.
|
||
- Subscriptions очищаются при component unmount, reload/disable flow и
|
||
`api.dispose()`.
|
||
|
||
`files`
|
||
|
||
- `files.list(relativeDir)` — list directory using a vault-relative path.
|
||
- `files.metadata(relativePath)` — returns file/folder/symlink metadata.
|
||
- `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit.
|
||
- `files.writeText(relativePath, content, options)` — atomically writes text via
|
||
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
|
||
control conflicts.
|
||
- `files.createFolder(relativePath)` — creates one folder when the parent exists.
|
||
- `files.move(from, to, options)` — moves a file or folder; rejects moving a
|
||
folder into itself and conflicts unless `options.overwrite` is true.
|
||
- `files.trash(relativePath)` — moves a file/folder into internal
|
||
`.verstak/trash/files/<trashId>/...` and returns trash metadata.
|
||
- Backend requires plugin exists, enabled, status `loaded`/`degraded`, open
|
||
vault, and `files.read`, `files.write`, or `files.delete`.
|
||
- All paths are canonical vault-relative slash paths. Backslashes, POSIX
|
||
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
|
||
symlink traversal, and public access to `.verstak/` are rejected.
|
||
- `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any
|
||
first path segment with that spelling are internal-only.
|
||
- `files.metadata` may report a final symlink as `type: "symlink"`, but
|
||
`files.list` through a symlink directory and all read/write/move/trash
|
||
operations through symlinks are forbidden in Milestone 6a.
|
||
- Files API is text-only for read/write in Milestone 6a. `readText` is limited
|
||
to UTF-8 regular files up to 2 MB. Binary streaming, watcher, restore,
|
||
external editor integration, and Files UI plugin are deferred.
|
||
|
||
`dispose`
|
||
|
||
- `dispose()` вызывается host'ом при cleanup. Plugin code обычно не вызывает его
|
||
напрямую. Он удаляет зарегистрированные command handlers и event subscriptions.
|
||
|
||
### Runtime boundaries
|
||
|
||
| Layer | Current status |
|
||
|---|---|
|
||
| Bundled frontend runtime | Functional for settings, capabilities, commands, events and text Files API |
|
||
| Backend validation | Checks plugin exists, enabled/loaded state, permissions and declarations |
|
||
| Security boundary | Not implemented; bundled plugins share the desktop frontend JS context |
|
||
| Sidecar/RPC/sandbox | Not implemented |
|
||
|
||
### Error boundary
|
||
|
||
- Ошибка в plugin view/settings placeholder не роняет shell
|
||
- ViewContainer показывает "⚠️ Plugin UI failed" fallback
|
||
- Error канал: `console.error` + видимый fallback в UI
|
||
|
||
## Frontend Bundle Contract
|
||
|
||
### Регистрация компонентов
|
||
|
||
Плагин регистрирует frontend компоненты через глобальную функцию `window.VerstakPluginRegister`:
|
||
|
||
```javascript
|
||
window.VerstakPluginRegister('plugin.id', {
|
||
components: {
|
||
'ComponentName': {
|
||
mount: function(containerEl, props, api) {
|
||
// containerEl — div, созданный PluginBundleHost
|
||
// api — ограниченный VerstakPluginAPI
|
||
containerEl.innerHTML = '<h1>Hello from plugin!</h1>';
|
||
},
|
||
unmount: function(containerEl) {
|
||
// Очистка при смене view
|
||
containerEl.innerHTML = '';
|
||
}
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
### VerstakPluginAPI
|
||
|
||
API объект передаётся в `mount()` и содержит plugin-scoped методы текущего
|
||
bundled runtime. Это реальный runtime contract для cooperative bundled plugins,
|
||
но не sandbox/security boundary.
|
||
|
||
| Свойство | Статус | Описание |
|
||
|---|---|---|
|
||
| `api.pluginId` | ✅ Работает | ID плагина |
|
||
| `api.settings.read(key?)` | ✅ Работает | Читает plugin-scoped settings через backend bridge |
|
||
| `api.settings.write(key, value)` | ✅ Работает | Пишет один settings key через backend bridge |
|
||
| `api.settings.writeAll(settings)` | ✅ Работает | Заменяет settings namespace плагина |
|
||
| `api.capabilities.list()` | ✅ Работает | Возвращает capability registry |
|
||
| `api.capabilities.get(id)` | ✅ Работает | Возвращает capability entry/status |
|
||
| `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` |
|
||
| `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command |
|
||
| `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler |
|
||
| `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus |
|
||
| `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus |
|
||
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
|
||
| `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого |
|
||
| `api.files.readText(relativePath)` | ✅ Работает | Читает UTF-8 regular file до 2 MB |
|
||
| `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy |
|
||
| `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder |
|
||
| `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks |
|
||
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет |
|
||
| `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
|
||
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
|
||
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |
|
||
|
||
Ограничения:
|
||
|
||
- permissions/capabilities checks являются contract/policy checks;
|
||
- bundled frontend plugins исполняются в общем JS-контексте;
|
||
- malicious JS не изолирован;
|
||
- sidecar process lifecycle, RPC transport и sandbox enforcement ещё не
|
||
реализованы.
|
||
- Files paths are slash-only vault-relative contract paths; backslashes,
|
||
Windows absolute paths, UNC paths, `.verstak` variants, traversal and symlink
|
||
operations are rejected by backend policy checks.
|
||
|
||
### Загрузка бандла
|
||
|
||
1. `PluginBundleHost` получает pluginId и componentId
|
||
2. Вызывает `App.GetPluginFrontendInfo(pluginId)` — получает entry/style/rootPath
|
||
3. Вызывает `App.GetPluginAssetContent(pluginId, entry)` — получает JS контент
|
||
4. Выполняет контент через `new Function(content)` — bundle вызывает `VerstakPluginRegister`
|
||
5. Находит компонент по componentId и вызывает `mount(container, props, api)`
|
||
6. При смене view — вызывает `unmount(container)` для старого компонента
|
||
|
||
### Безопасность asset path
|
||
|
||
| Правило | Проверка |
|
||
|---|---|
|
||
| Нет абсолютных путей | Пути, начинающиеся с `/` или `\`, отклоняются |
|
||
| Нет path traversal | Пути, содержащие `..`, отклоняются |
|
||
| Нет выхода за root | После `filepath.Join` проверяется, что путь внутри plugin root |
|
||
| Только существующие файлы | `os.ReadFile` возвращает ошибку если файл не существует |
|
||
|
||
### manifest frontend config
|
||
|
||
```json
|
||
{
|
||
"frontend": {
|
||
"entry": "frontend/dist/index.js",
|
||
"style": "frontend/style.css"
|
||
}
|
||
}
|
||
```
|
||
|
||
## Reload
|
||
|
||
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
|
||
|
||
1. Unregister all non-core capabilities.
|
||
2. Re-register core capabilities + vault + workspace (если открыт).
|
||
3. Re-scan discovery directories.
|
||
4. For each plugin: re-run capability resolution.
|
||
5. **Unregister contributions** before re-registering (предотвращает дубли).
|
||
6. Register contributions for loaded/degraded plugins (disabled/failed — не регистрируются).
|
||
7. Update plugins list.
|
||
|
||
Frontend вызывает это при нажатии "Reload" в Plugin Manager.
|
||
|
||
## Vault Core Capability
|
||
|
||
- `verstak/core/vault/v1` — регистрируется в `main.go` после остальных core capabilities, когда vault инициализирован.
|
||
- Vault layout: `<base>/VerstakVault/.verstak/` с подпапками (см. ниже).
|
||
- Plugin namespace paths: `plugin-data/<id>`, `plugin-settings/<id>`, `plugin-cache/<id>`.
|
||
- Vault events: `vault.created`, `vault.opened`, `vault.closed`, `vault.error`.
|
||
- Vault status: `not-created`, `closed`, `open`, `error`.
|
||
- Path traversal protection через `ResolveSafePath`.
|
||
|
||
### Vault Directory Layout
|
||
|
||
```
|
||
<base>/
|
||
VerstakVault/ ← vault root (создаётся CreateVault)
|
||
.verstak/
|
||
vault.json ← VaultMeta (schemaVersion, vaultId, createdAt, app)
|
||
plugin-data/ ← per-plugin data namespaces
|
||
<plugin-id>/
|
||
plugin-settings/ ← per-plugin settings namespaces
|
||
<plugin-id>/
|
||
plugin-cache/ ← per-plugin cache namespaces
|
||
<plugin-id>/
|
||
trash/ ← soft-deleted items
|
||
logs/ ← vault-scoped logs
|
||
```
|
||
|
||
### Vault API
|
||
|
||
| Метод | Описание |
|
||
|---|---|
|
||
| `CreateVault(path)` | Создаёт `VerstakVault/` с `.verstak/` layout и `vault.json`. Публикует `vault.created`. |
|
||
| `OpenVault(path)` | Открывает существующий vault, валидирует `vault.json`. Публикует `vault.opened`. |
|
||
| `CloseVault()` | Закрывает vault, сбрасывает path/meta. Публикует `vault.closed`. |
|
||
| `GetVaultStatus()` | Возвращает текущий статус: `not-created`, `closed`, `open`, `error`. |
|
||
| `GetVaultPath()` | Возвращает путь к vault root. |
|
||
| `GetVaultMeta()` | Возвращает `VaultMeta` (vaultId, schemaVersion, timestamps). |
|
||
| `ResolveSafePath(rel)` | Безопасно резолвит относительный путь внутри vault. Блокирует path traversal. |
|
||
| `GetPluginDataPath(id)` | Возвращает (и создаёт) `plugin-data/<id>/`. |
|
||
| `GetPluginSettingsPath(id)` | Возвращает (и создаёт) `plugin-settings/<id>/`. |
|
||
| `GetPluginCachePath(id)` | Возвращает (и создаёт) `plugin-cache/<id>/`. |
|
||
|
||
### Vault Events
|
||
|
||
| Event | Когда публикуется | Payload |
|
||
|---|---|---|
|
||
| `vault.created` | После успешного `CreateVault` | `path`, `vaultId` |
|
||
| `vault.opened` | После успешного `OpenVault` | `path`, `vaultId` |
|
||
| `vault.closed` | После `CloseVault` | `vaultId` |
|
||
| `vault.error` | При ошибках операций | `error` |
|
||
|
||
### Vault Status Flow
|
||
|
||
```
|
||
not-created ──CreateVault──▶ open ──CloseVault──▶ closed
|
||
│ │
|
||
└──OpenVault─────────┘
|
||
```
|
||
|
||
## Файлы реализации
|
||
|
||
| Файл | Назначение |
|
||
|---|---|
|
||
| `internal/core/plugin/plugin.go` | Manifest, ValidateManifest, DiscoverPlugins, Status |
|
||
| `internal/core/capability/registry.go` | CapabilityRegistry |
|
||
| `internal/core/contribution/registry.go` | ContributionRegistry |
|
||
| `internal/core/permissions/registry.go` | PermissionsRegistry |
|
||
| `internal/core/events/bus.go` | EventBus |
|
||
| `internal/api/app.go` | Wails API, ReloadPlugins |
|
||
| `internal/core/vault/vault.go` | Vault service: CreateVault, OpenVault, CloseVault, ResolveSafePath, plugin namespace paths |
|
||
|| `internal/core/vault/vault_test.go` | Vault tests: layout creation, open/close, path traversal, events |
|
||
|| `internal/core/storage/api.go` | Plugin storage API: settings/data/cache JSON with namespace isolation |
|
||
|| `internal/core/storage/api_test.go` | Storage tests: write/read, path traversal, atomic write |
|
||
|| `internal/core/appsettings/manager.go` | App settings manager: Load/Save/Update, recent vaults, defaults |
|
||
|| `internal/core/appsettings/manager_test.go` | App settings tests: defaults, corrupt config, recent dedup |
|
||
|| `internal/core/pluginstate/manager.go` | Vault plugin state: enable/disable, desired plugins, missing-installed |
|
||
|| `internal/core/pluginstate/manager_test.go` | Plugin state tests: enable/disable, persist, corrupt, missing |
|
||
|
||
---
|
||
|
||
## App Settings
|
||
|
||
App settings хранятся **локально** (НЕ внутри vault) в `~/.config/verstak/config.json`.
|
||
|
||
### Поле | Назначение
|
||
---|---
|
||
`currentVaultPath` | Путь к текущему vault
|
||
`recentVaults` | Список недавних vault (max 10, без дублей)
|
||
`theme` | Тема (dark/light)
|
||
`devMode` | Режим разработки
|
||
`userPluginsDir` | Директория пользовательских плагинов
|
||
`windowState` | Состояние окна (размеры, максимизация)
|
||
`lastOpenedAt` | Время последнего запуска
|
||
|
||
### Правила
|
||
- Если config отсутствует — создаётся с defaults
|
||
- Если config битый — backup + создание defaults с понятной ошибкой
|
||
- `currentVaultPath` при запуске проверяется и vault открывается автоматически
|
||
- Secrets НЕ хранятся в app settings
|
||
|
||
---
|
||
|
||
## Vault Plugin State
|
||
|
||
Vault plugin state хранится **внутри vault** в `.verstak/plugins.json`.
|
||
|
||
### Структура
|
||
|
||
```json
|
||
{
|
||
"schemaVersion": 1,
|
||
"enabledPlugins": ["verstak.platform-test"],
|
||
"disabledPlugins": [],
|
||
"desiredPlugins": [
|
||
{
|
||
"id": "verstak.platform-test",
|
||
"version": "0.1.0",
|
||
"source": "official"
|
||
}
|
||
],
|
||
"updatedAt": "2026-06-17T..."
|
||
}
|
||
```
|
||
|
||
### Поле | Назначение
|
||
---|---
|
||
`enabledPlugins` | Плагины, которые активны в этом vault
|
||
`disabledPlugins` | Плагины, которые явно отключены
|
||
`desiredPlugins` | Плагины, которые нужны этому vault (для будущей синхронизации)
|
||
`updatedAt` | Время последнего обновления
|
||
|
||
### Правила
|
||
- Enabled/disabled состояние относится к конкретному vault
|
||
- Disabled plugin не регистрирует provides/contributions
|
||
- Plugin settings остаются в `.verstak/plugin-settings/<id>/settings.json`
|
||
- Отсутствие `plugins.json` → создаётся с defaults
|
||
- Битый `plugins.json` → backup + defaults с понятной ошибкой
|
||
- App settings НЕ хранятся внутри vault
|
||
- Plugin packages НЕ хранятся в vault settings
|
||
|
||
### Installed vs Enabled
|
||
|
||
- **Installed** — plugin package существует в discovery directory
|
||
- **Enabled** — plugin активен в vault plugin state
|
||
- **Disabled** — plugin установлен, но отключен в vault
|
||
- **Missing installed** — plugin listed в `desiredPlugins`, но package отсутствует локально
|
||
|
||
### Missing Installed Plugins
|
||
|
||
Состояние для будущей синхронизации:
|
||
- `desiredPlugins` может содержать plugin, которого нет локально
|
||
- Plugin Manager показывает "Missing installed plugin"
|
||
- Auto-install пока НЕ делается
|
||
- Показывается подсказка: "Install official plugin package"
|
||
|
||
|
||
## UI Layout
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ App.svelte │
|
||
│ ┌──────────┬──────────────────────────────────────┐ │
|
||
│ │ Sidebar │ Content area │ │
|
||
│ │ │ │ │
|
||
│ │ Verstak │ PluginManager | ViewContainer │ │
|
||
│ │ │ │ │
|
||
│ │ 🧩 Plugin│ (padding: 1.5rem) │ │
|
||
│ │ Manager│ │ │
|
||
│ │ │ │ │
|
||
│ │ Plugins │ │ │
|
||
│ │ 📌 item1 │ │ │
|
||
│ │ 📌 item2 │ │ │
|
||
│ │ │ │ │
|
||
│ │ ● Vault │ │ │
|
||
│ └──────────┴──────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Sidebar** (220px): навигация (Plugin Manager), plugin sidebar items, vault status
|
||
- **Content**: Plugin Manager или ViewContainer в зависимости от выбранного view
|
||
- **Vault Selection**: полноэкранный экран, показывается когда vault не открыт
|
||
|
||
## Milestone 4b — UI Completion (2026-06-17)
|
||
|
||
Сделано:
|
||
- VaultSelection.svelte: исправлен flow (CreateVault → OpenVault → SetCurrentVault)
|
||
- Sidebar.svelte: полная навигация с отступами, plugin sidebar items, vault status
|
||
- App.svelte: обработка `verstak:nav` событий, global reset стилей
|
||
- PluginManager.svelte: исправлены отступы header
|
||
|
||
Проверки:
|
||
- `go test ./...` — 52 PASS
|
||
- `./scripts/check.sh` — ✅
|
||
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
|
||
- `./scripts/build.sh` — ✅
|
||
|
||
## Workspace / Cases Core Capability
|
||
|
||
Workspace — центральная модель Верстака вокруг "дел". Это НЕ notes/files — это фундамент.
|
||
|
||
### Ноды
|
||
|
||
| Поле | Тип | Описание |
|
||
|------|-----|----------|
|
||
| `id` | UUID | Стабильный идентификатор |
|
||
| `parentId` | string | ID родителя (пусто для root) |
|
||
| `type` | space/case/folder | Тип ноды |
|
||
| `title` | string | Название |
|
||
| `status` | active/sleeping/archived | Жизненный цикл |
|
||
| `tags` | string[] | Теги |
|
||
| `order` | int | Порядок среди siblings |
|
||
| `createdAt` | RFC3339Nano | Создан |
|
||
| `updatedAt` | RFC3339Nano | Обновлён |
|
||
|
||
### Хранение
|
||
|
||
`<vault>/.verstak/workspace.json` — атомарная запись (temp + rename).
|
||
|
||
### API
|
||
|
||
- `GetWorkspaceTree()` — полное дерево
|
||
- `CreateWorkspaceNode(parentID, type, title)` — создать
|
||
- `RenameWorkspaceNode(id, title)` — переименовать
|
||
- `MoveWorkspaceNode(id, newParentID)` — переместить
|
||
- `ArchiveWorkspaceNode(id)` — архивировать
|
||
- `SetCurrentWorkspaceNode(id)` — выбрать текущую
|
||
- `GetCurrentWorkspaceNode()` — получить текущую
|
||
|
||
### Capability
|
||
|
||
`verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован.
|
||
|
||
### Правила
|
||
|
||
- Root node создаётся при создании vault
|
||
- Порядок children стабилен (sort by order)
|
||
- Нельзя переместить ноду в себя или в своего потомка
|
||
- Архивирование — soft delete (status = archived)
|
||
- Corrupt JSON → backup + defaults
|
||
|
||
### Типы нод
|
||
|
||
| Тип | Назначение |
|
||
|-----|-----------|
|
||
| `space` | Рабочее пространство (root) |
|
||
| `case` | Дело |
|
||
| `folder` | Папка |
|
||
|
||
НЕ добавляются: note, file, action, secret, worklog, link — это плагины.
|
||
|
||
### Lifecycle Events
|
||
|
||
**Planned (not yet implemented in runtime):**
|
||
- `workspace.node.created`
|
||
- `workspace.node.renamed`
|
||
- `workspace.node.moved`
|
||
- `workspace.node.archived`
|
||
- `workspace.node.selected`
|
||
- `workspace.error`
|
||
|
||
### UI
|
||
|
||
WorkspaceTree в sidebar:
|
||
- Дерево с expand/collapse
|
||
- Создание case/folder
|
||
- Выбор текущей ноды
|
||
- Индикатор статуса (active/archived/sleeping)
|
||
|
||
---
|
||
|
||
## Build Scripts
|
||
|
||
В `verstak-desktop/scripts/` есть два скрипта:
|
||
|
||
### `build.sh` — локальная детерминированная сборка
|
||
|
||
- Собирает **только** `verstak-desktop` (core platform).
|
||
- Не трогает другие репозитории.
|
||
- **Fail-fast**: любая ошибка (go vet, go test, frontend build, wails build) прерывает сборку.
|
||
- Проверяет: deps → frontend build → go mod download → go vet → go build → go test → wails build + plugin copy.
|
||
- Используется в CI и для повседневной работы над core.
|
||
|
||
### `update-and-build-all.sh` — dev helper для полной пересборки связки
|
||
|
||
- **Не для CI.** Только для разработки, когда нужно быстро собрать всё вместе.
|
||
- Шаги:
|
||
1. `git pull --ff-only` во всех 6 репозиториях
|
||
2. Сборка official plugins (frontend npm build + backend go build для каждого плагина)
|
||
3. Копирование собранных плагинов в `verstak-desktop/plugins/`
|
||
4. Запуск `build.sh` для сборки desktop
|
||
- Ошибки pull и сборки плагинов не прерывают скрипт (best-effort), но ошибка build.sh прерывает (fail-fast).
|