Compare commits

...

12 Commits

Author SHA1 Message Date
mirivlad 3bf67fb1e9 Убрано log.Fatalf в main.go для workspace/v1 (теперь platform capability) 2026-07-18 18:58:26 +08:00
mirivlad 2ce87f6fb1 Убрано дублирование регистрации verstak/core/workspace/v1 2026-07-18 18:57:06 +08:00
mirivlad 0b5fd1cac5 Добавлен verstak/core/workspace/v1 в platform capabilities 2026-07-18 18:55:52 +08:00
mirivlad b10f243e34 Вложенные Дела, folder metadata, плагин workspace-folders
- Nested workspace model: маркеры .verstak/workspace.json на любой глубине
- Folder metadata API: иконка, цвет, порядок (.verstak/folder-metadata/)
- MoveWorkspace, GetFolderMetadata, SetFolderMetadata Wails биндинги
- Contribution point workspaceTree — singleton в registry
- Sidebar.svelte монтирует PluginBundleHost при наличии workspaceTree
- VerstakPluginAPI: workspaces.* и folders.* методы
- WorkspaceTree.svelte: рекурсивное дерево, выбор родительской папки
- i18n: строки EN+RU, release notes v0.1.0-alpha.8
2026-07-18 18:54:42 +08:00
mirivlad 1ca759cc2e Документация: удалены реализованные milestone-планы, обновлён PLUGIN_RUNTIME и NOTES_FILES_PLUGIN_PLAN 2026-07-18 18:06:44 +08:00
mirivlad 4ae08ef88e Use paginated blob transport for file sync 2026-07-17 05:09:41 +08:00
mirivlad e3d8078ad5 Harden core snapshot sync and workspace lifecycle 2026-07-17 04:10:59 +08:00
mirivlad ba0ba5f8c4 fix: make branded icon generation deterministic 2026-07-15 19:24:59 +08:00
mirivlad bf965e8bf3 fix: use Verstak brand icon in desktop builds 2026-07-15 19:17:13 +08:00
mirivlad 6eeebd39ad fix: restore Windows tray lifecycle 2026-07-15 18:56:22 +08:00
mirivlad 3c1f2c7177 docs: add historical release notes 2026-07-15 07:50:17 +08:00
mirivlad a9576ac141 docs: require human release notes 2026-07-15 07:42:02 +08:00
72 changed files with 5660 additions and 2163 deletions

View File

@ -124,7 +124,7 @@ Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows
## Background and tray mode ## Background and tray mode
Closing the main window keeps Verstak running in the system tray. Use **Show Verstak** to bring it back and **Quit** to exit the application completely. This lets scheduled Todo reminders continue while the window is hidden. After the tray icon is ready, closing the main window keeps Verstak running in the system tray. One left click restores and focuses the window; a right click opens the native **Show Verstak** and **Quit** menu. **Quit** exits the application completely. If the tray cannot start, closing the window exits normally rather than leaving an unreachable process. Scheduled Todo reminders continue while the window is hidden.
### Verify a download ### Verify a download
@ -224,6 +224,33 @@ For synchronization between devices, deploy the optional self-hosted
Each vault is connected separately. The local vault remains the primary copy of your data. Each vault is connected separately. The local vault remains the primary copy of your data.
Desktop core maintains an atomic local sync snapshot under `.verstak/sync/`.
It scans when a vault opens, before a manual sync, and after debounced external
file-watcher events, so files changed in an editor or while Desktop was closed
are reconciled too. The watcher only speeds up discovery. A first connection
pulls before it publishes a local bootstrap: an empty local vault does not send
deletes, and incompatible files become visible conflicts rather than silent
overwrites. Pairing can optionally use an existing remote vault ID to restore
that scope on a new device.
The current transport supports ordinary files and folders plus workspace (Deal)
create/rename/trash/restore with a durable workspace UUID. `.verstak`, trash,
temporary files, and symlinks are excluded from ordinary file sync. UTF-8 text
remains inline only within the existing 2 MB bound; binary and larger content
is staged privately under `.verstak/sync/blobs`, uploaded through the scoped
Blob API, and represented in the operation log only by SHA-256 and size. The
download is streamed, hash/size-verified, and atomically applied. A file over
the configured server blob limit or otherwise unsupported remains a visible
unresolved warning instead of being treated as synchronized.
Pull is paginated. Desktop applies operations in increasing server sequence,
persists its cursor only after each successful operation, stops before later
pages at the first failure, and retries after restart. Blob references are
authorized per user/vault; a missing or corrupt blob likewise leaves the cursor
unchanged. The server is optional and file bytes are not end-to-end encrypted.
Operation-log retention/checkpoints and synchronization of Secrets, plugin
settings, Todo, Journal, Activity, and Browser Inbox remain future milestones.
## Build from source ## Build from source
### Requirements ### Requirements
@ -232,9 +259,9 @@ Each vault is connected separately. The local vault remains the primary copy of
* Node.js 20 or newer with npm; * Node.js 20 or newer with npm;
* Python 3; * Python 3;
* Git; * Git;
* ImageMagick (`magick`) to generate the branded tray and application icons;
* Wails v2 build dependencies; * Wails v2 build dependencies;
* WebKitGTK development packages on Linux. * WebKitGTK development packages on Linux.
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
See the See the
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/) [Wails installation documentation](https://wails.io/docs/gettingstarted/installation/)

View File

@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
## Фоновая работа и трей ## Фоновая работа и трей
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **«Показать Верстак»**, чтобы вернуть окно, или **«Выйти»**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто. После готовности значка в трее закрытие главного окна не завершает Верстак. Один левый клик возвращает и фокусирует окно, правый клик открывает нативное меню **«Показать Верстак»** и **«Выйти»**. **«Выйти»** полностью завершает приложение. Если трей не удалось запустить, закрытие окна завершает приложение обычным образом и не оставляет недоступный процесс. Пока окно скрыто, напоминания Todo продолжают работать.
### Проверка скачанного файла ### Проверка скачанного файла
@ -232,9 +232,9 @@ sha256sum -c SHA256SUMS --ignore-missing
* Node.js 20 или новее и npm; * Node.js 20 или новее и npm;
* Python 3; * Python 3;
* Git; * Git;
* ImageMagick (`magick`) для генерации фирменных значков трея и приложения;
* зависимости для сборки Wails v2; * зависимости для сборки Wails v2;
* пакеты разработки WebKitGTK в Linux. * пакеты разработки WebKitGTK в Linux.
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
Зависимости для конкретного дистрибутива перечислены в Зависимости для конкретного дистрибутива перечислены в
[документации Wails](https://wails.io/docs/gettingstarted/installation/). [документации Wails](https://wails.io/docs/gettingstarted/installation/).

View File

@ -1,403 +0,0 @@
# Milestone 6a Files Core Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` or `superpowers:executing-plans` to
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for
> tracking.
**Goal:** Add a safe backend Files core service and plugin bridge for
vault-relative text file operations.
**Architecture:** Files Core is a backend service under `internal/core/files`
that accepts vault-relative paths, enforces reserved path policy, and performs
atomic text writes. The Wails API exposes plugin-scoped methods guarded by plugin
state and `files.*` permissions; SDK types describe the bridge shape.
**Tech Stack:** Go backend services/tests, Wails-bound API methods, TypeScript
SDK type definitions, existing Playwright/Vitest checks.
---
## Implementation Status
Milestone 6a is implemented.
Actual backend package:
- `internal/core/files/types.go`
- `internal/core/files/path_policy.go`
- `internal/core/files/service.go`
- `internal/core/files/*_test.go`
Actual plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Actual bundled frontend API:
- `api.files.list(relativeDir)`
- `api.files.metadata(relativePath)`
- `api.files.readText(relativePath)`
- `api.files.writeText(relativePath, content, options)`
- `api.files.createFolder(relativePath)`
- `api.files.move(fromRelativePath, toRelativePath, options)`
- `api.files.trash(relativePath)`
Implemented limits:
- canonical vault-relative slash paths only;
- backslashes, POSIX absolute paths, Windows drive paths, UNC paths, traversal,
null bytes, and empty file paths are rejected;
- `.verstak/` is reserved case-insensitively and hidden from public Files API;
- metadata may report symlinks, but list-through-symlink and
read/write/move/trash through symlink are forbidden;
- text read/write only, with `readText` limited to UTF-8 files up to 2 MB;
- trash uses `.verstak/trash/files/<trashId>/...` with restore metadata, but
restore itself is deferred;
- binary streaming, watcher, external editor, Files UI, Notes service, sidecar,
sandbox/security isolation deferred.
---
## Scope
Implement:
- Backend Files service.
- Safe vault-relative path handling.
- Reserved `.verstak/` policy.
- List files.
- Read/write text files.
- Create folder.
- Move path.
- Trash path.
- Atomic writes.
- Backend tests.
- SDK bridge shape draft.
Do not implement:
- Full Notes plugin.
- Notes UI.
- Sync.
- Watcher.
- Binary streaming.
- External editor integration.
- Sidecar/security isolation.
## Canonical Policy
All public Files API methods use canonical vault-relative slash paths.
Rejected inputs:
- absolute paths;
- backslashes;
- Windows drive paths and UNC/network paths;
- paths containing `..` after normalization;
- null bytes;
- empty paths where a file path is required;
- access to `.verstak/` through the public plugin Files API, including
`.Verstak` case variants.
Delete behavior:
- `TrashVaultPath` moves files/folders into `.verstak/trash`.
- Trash metadata includes `originalPath`, `deletedAt`, `originalType`,
`trashId`, and `basename`.
- Permanent delete is out of scope.
- Restore is out of scope.
Write behavior:
- Text writes use a temporary file in the target directory and rename into place.
- Existing files are overwritten only when the method explicitly allows overwrite.
- Parent directory must exist unless the method explicitly creates it.
Binary behavior:
- Binary files can appear in list/metadata results.
- Binary read/write streaming is out of scope.
## Public Backend Shape
Implemented plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Permission mapping:
- `ListVaultFiles`, `GetVaultFileMetadata`, `ReadVaultTextFile`: `files.read`.
- `WriteVaultTextFile`, `CreateVaultFolder`, `MoveVaultPath`: `files.write`.
- `TrashVaultPath`: `files.delete`.
## Data Types
Create `internal/core/files/types.go`:
```go
package files
type EntryKind string
const (
KindFile EntryKind = "file"
KindDirectory EntryKind = "directory"
)
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
Kind EntryKind `json:"kind"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
IsText bool `json:"isText"`
IsBinary bool `json:"isBinary"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
}
```
## Task 1: Path Policy
**Files:**
- Create: `internal/core/files/path.go`
- Create: `internal/core/files/path_test.go`
- [x] Add `NormalizeVaultRelativePath(relative string) (string, error)`.
- [x] Reject absolute paths, null bytes, `..`, and empty file paths.
- [x] Preserve path case, including canonical `Notes`.
- [x] Add `IsReservedPath(relative string) bool` returning true for `.verstak`
and `.verstak/...`.
- [x] Add tests:
`TestNormalizeRejectsAbsolutePath`,
`TestNormalizeRejectsTraversal`,
`TestNormalizeRejectsNullByte`,
`TestNormalizePreservesCase`,
`TestReservedPathPolicy`.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 2: Files Service
**Files:**
- Create: `internal/core/files/service.go`
- Create/modify: `internal/core/files/service_test.go`
- [x] Define `Service` with a vault dependency that can return the current vault
root and status.
- [x] Implement `List(relativeDir string) ([]Entry, error)`.
- [x] Implement `Metadata(relativePath string) (Entry, error)`.
- [x] Implement `ReadText(relativePath string) (string, error)`.
- [x] Implement `WriteText(relativePath, content string, overwrite bool) (Entry, error)`.
- [x] Implement `Mkdir(relativePath string) (Entry, error)`.
- [x] Implement `Move(fromRelativePath, toRelativePath string, overwrite bool) (Entry, error)`.
- [x] Implement `Trash(relativePath string) (Entry, error)`.
- [x] Use the shared path policy for every public method.
- [x] Block `.verstak` paths in every public method.
- [x] Add tests for closed vault, list, metadata, text read/write, mkdir, move,
trash, overwrite false conflict, overwrite true replace, and reserved path
rejection.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 3: Atomic Writes
**Files:**
- Modify: `internal/core/files/service.go`
- Modify: `internal/core/files/service_test.go`
- [x] Write text content to a temp file in the target directory.
- [x] Rename the temp file into the final path only after successful write.
- [x] Remove temp file on write failure.
- [x] Add test `TestWriteTextIsAtomicOnFailure` using a controlled failing path
or permission-denied directory.
- [x] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 4: Permissions And Capabilities
**Files:**
- Modify: `internal/core/permissions/registry.go`
- Modify: `main.go`
- Modify: `internal/api/app_test.go`
- [x] Register permissions: `files.read`, `files.write`, `files.delete`.
- [x] Register core capability `verstak/core/files/v1` when vault services are
initialized.
- [x] Add API guard tests proving each Files bridge method rejects plugins that
are missing the required permission.
- [x] Run:
```bash
go test ./internal/core/permissions ./internal/api
```
Expected: permission registry and API tests pass.
## Task 5: Wails API Bridge
**Files:**
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.d.ts`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.js`
- [x] Add `files.Service` to `api.App`.
- [x] Add plugin-scoped methods listed in "Public Backend Shape".
- [x] Use `requirePluginAccess(pluginID, permission)` for every method.
- [x] Return readable errors for closed vault, missing file, reserved path,
conflict, and missing permission.
- [x] Add tests for successful read/write/list/mkdir/move/trash through `App`.
- [x] Run:
```bash
go test ./internal/api
```
Expected: API tests pass.
## Task 6: Frontend Plugin API Draft
**Files:**
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/src/lib/test/wails-mock.js`
- Add/modify focused frontend tests under `frontend/e2e/` only if existing test
coverage cannot validate the shape outside Playwright.
- [x] Add `api.files.list(relativeDir)`.
- [x] Add `api.files.metadata(relativePath)`.
- [x] Add `api.files.readText(relativePath)`.
- [x] Add `api.files.writeText(relativePath, content, options)`.
- [x] Add `api.files.mkdir(relativePath)`.
- [x] Add `api.files.move(fromRelativePath, toRelativePath, options)`.
- [x] Add `api.files.trash(relativePath)`.
- [x] Keep all calls plugin-scoped; plugin code must not pass `pluginId`.
- [x] Mock readable errors for reserved path and missing permission.
- [x] Run:
```bash
cd frontend
npm run build
```
Expected: frontend build passes.
## Task 7: SDK Bridge Shape Draft
**Files:**
- Modify: `../verstak-sdk/src/plugin-api.ts`
- Modify: `../verstak-sdk/src/test-utils.ts`
- Modify: `../verstak-sdk/src/plugin-api.test.ts`
- [x] Add `files` API TypeScript interfaces matching the frontend API names.
- [x] Add mock Files API methods in `createMockPluginAPI`.
- [x] Add contract tests for API shape, text write/read, reserved path error, and
trash result shape.
- [x] Run:
```bash
cd ../verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: SDK check, build, and tests pass.
## Task 8: Documentation
**Files:**
- Modify: `docs/PLUGIN_RUNTIME.md`
- Modify: `docs/NOTES_FILES_PLUGIN_PLAN.md`
- [x] Document Files Core API as functional for Milestone 6a.
- [x] Keep Notes API documented as planned until Milestone 6b or later.
- [x] Document `.verstak` reserved path policy.
- [x] Document slash-only path policy, Windows/UNC rejection, and symlink policy.
- [x] Document text-only write support and deferred binary streaming.
## Task 9: Final Verification
- [x] Run desktop backend tests:
```bash
cd verstak-desktop
go test ./...
```
- [x] Run desktop frontend build:
```bash
cd verstak-desktop/frontend
npm run build
```
- [x] Run desktop e2e:
```bash
cd verstak-desktop/frontend
npm run test:e2e -- --reporter=list
```
- [x] Run official plugins checks:
```bash
cd verstak-official-plugins
./scripts/check.sh
./scripts/build.sh
```
- [x] Run SDK checks:
```bash
cd verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: all commands exit 0. Existing Svelte unused CSS warnings are acceptable
only if they remain warnings and do not fail the build.

View File

@ -1,148 +0,0 @@
# Milestone 6b - Open/Edit Provider Registry + Workbench Routing Skeleton
This milestone adds the minimal infrastructure layer for open/edit routing before
Files UI, Notes UI, or editor implementation starts. It does not implement those
plugins and does not add a concrete core-owned Markdown editor.
## Existing Architecture Found
| Path | Section/title | Summary |
|---|---|---|
| `../verstak-docs/00_README.md` | Main architecture invariant | Core does not know concrete notes, file manager, or markdown editor features. It owns vault, plugin runtime, capability registry, contribution points, permissions, settings, events, storage, and UI shell. |
| `../verstak-docs/01_Product_Vision.md` | What is not in core / platform goal | Markdown editor, file manager, preview, and notes workflow are plugins. Users should be able to replace the markdown editor or install multiple editors. |
| `../verstak-docs/02_Platform_Architecture.md` | UI Shell / Capability Registry | UI Shell knows contribution points, not concrete note editor or file preview implementations. Files plugin checks editor/viewer capabilities instead of depending on `official.markdown-editor`. |
| `../verstak-docs/03_Repositories.md` | Repository split | `verstak-desktop` is Core Platform + UI Shell and does not contain mandatory notes, file manager, or markdown editor modules. Official plugins live separately. |
| `../verstak-docs/04_Plugin_System.md` | Goal / capabilities instead of plugin names / contribution points | Notes, file manager, editor, and viewer are plugin functions. The docs explicitly reject `requires: ["official.markdown-editor"]` and prefer `optionalRequires: ["editor.text.markdown"]`. |
| `../verstak-docs/05_Official_Plugins.md` | official.files / official.notes / official.markdown-editor | Files optionally depends on editor/viewer capabilities. Markdown editor provides `editor.text`, `editor.text.markdown`, and `editor.note.markdown`, and must not own note storage or depend directly on `official.notes`. |
| `../verstak-docs/06_Migration_Strategy.md` | Do not / Definition of Done | Do not make notes/files/editor mandatory core parts. The platform transition is done only when notes/files/editor/preview/activity work as plugins. |
| `docs/NOTES_FILES_PLUGIN_PLAN.md` | Canonical Notes Model | Notes are ordinary Markdown files in canonical `Notes/` folders. No lowercase `notes`, no `.verstak/notes`, no plugin-data note content, no UUID-only filenames. |
| `docs/NOTES_FILES_PLUGIN_PLAN.md` | Files Service Model | Files Core is raw vault file access and does not understand note semantics. Milestone 6a exposes safe text file methods and defers UI, watcher, binary streaming, external editor, and restore. |
| `docs/PLUGIN_RUNTIME.md` | Contribution Points / Bundled Frontend Plugin API | Current runtime hosts views/sidebar/settings/commands and exposes plugin-scoped Files API. `fileActions`, `noteActions`, `contextMenuEntries`, search/activity/status bar entries are registered but not hosted. |
| `internal/core/contribution/registry.go` | Contribution registry | Registry has the established contribution points and now extends that model with `openProviders`. |
| `internal/core/plugin/plugin.go` | Manifest contributions | Plugin manifest types now include `openProviders` alongside existing contribution points. |
| `frontend/src/lib/shell/WorkbenchHost.svelte` and `frontend/src/lib/plugin-host/PluginBundleHost.svelte` | Frontend plugin host | Workbench mounts the selected provider component by plugin id and component id. It remains generic and does not know a concrete editor. |
| `frontend/src/lib/plugin-host/VerstakPluginAPI.js` | Plugin API | Bundled plugins can call `api.workbench.openResource()` and `api.workbench.editResource()` in addition to settings, capabilities, events, commands, and Files API. |
| `../verstak-sdk/src/types.ts` and `../verstak-sdk/schemas/manifest.json` | SDK contribution contracts | SDK types/schema define `openProviders`, `OpenResourceRequest`, provider supports, and `files.*` permissions. |
## What Matches The Desired Model
- Files/Notes/Editor are already documented as plugins, not core modules.
- Official plugins are expected to use the same runtime as community plugins.
- Files plugin is already documented as capability-driven, not hard-wired to a markdown editor.
- Markdown editor is already documented as replaceable via capabilities.
- Notes are already documented as Markdown files under canonical `Notes/` folders, without `.verstak/notes`, UUID note entities, or a second storage truth.
- Desktop code has no hardcoded Markdown editor component.
- Existing plugin host can mount arbitrary plugin components, which is enough foundation for an editor provider host.
## Contradictions Found
- `docs/NOTES_FILES_PLUGIN_PLAN.md` still said Files Core API/capability/permissions were unavailable, while later sections and code show Milestone 6a implemented them.
- `../verstak-docs/05_Official_Plugins.md` calls notes "first-class Verstak entities". That is acceptable only as UI semantics; implementation must not create a separate note storage entity.
- `../verstak-docs/05_Official_Plugins.md` lists "Open externally" as a Files fallback. External open remains deferred and must not enter Milestone 6b.
- `../verstak-sdk/schemas/manifest.json` previously did not include `files.read`, `files.write`, or `files.delete` in the permissions enum, while SDK TS types, desktop permissions, and official `platform-test` manifest already used them. Milestone 6b resolves this 6a contract cleanup item.
## Missing Before 6b
- `openProviders` contribution point.
- `OpenResourceRequest` contract.
- Workbench open/edit routing API for Files/Notes plugins.
- Provider selection model using resource kind, extension/mime, notes context, user preference, provider priority, deterministic fallback, and disabled provider fallback.
- User preferences for default text editor provider, default markdown editor provider, and default notes-context markdown editor provider.
- Host slot/tab that mounts the selected provider component with an open resource request.
- Tests for provider registration, selection, preferences, disabled provider fallback, and notes-context routing.
## Added In 6b
- `contributes.openProviders` in SDK schema/types and desktop manifest structs.
- Desktop contribution registry support for registering, replacing, listing, and
unregistering open providers.
- `OpenResourceRequest`, `OpenResourceContext`, `OpenResourceResult`, and
opened-resource state in the Workbench routing skeleton.
- Provider selection by resource kind, extension/mime, `generic-text`,
`generic-markdown`, or `notes-markdown` context, user preference, priority,
deterministic `pluginId/providerId` fallback, and active plugin filtering.
- `workbench.open` policy permission for plugins that request Workbench routing.
- Draft app settings preferences for default text, markdown, and notes-context
markdown providers.
- `api.workbench.openResource()` and `api.workbench.editResource()` exposed to
frontend plugin bundles.
- Minimal Workbench host that mounts the selected provider component from the
selected provider plugin.
- `no-provider` fallback state when no matching provider exists.
- `platform-test` diagnostic open provider used only to prove routing.
## Decision
Open/edit provider should be an extension of the existing contribution registry,
not a parallel system. It should add `openProviders` beside `views`, `commands`,
`fileActions`, and `noteActions`.
Capabilities remain useful for broad availability and degraded mode, but they are
too coarse for choosing between multiple providers. Provider selection needs a
declarative provider contribution with `supports`, priority, and component id.
## Correct Notes Model
- Notes are a contextual view over ordinary Markdown files under canonical
`Notes/` folders.
- `.md` inside `Notes/` opens through the selected markdown editor with
notes-context.
- `.md` outside `Notes/` opens through the selected markdown editor in generic
markdown mode.
- Plain text opens through the selected text editor provider in `generic-text`
context.
- Files and Notes call open/edit resource; neither embeds a concrete editor.
- Editor provider selection belongs to Workbench/provider registry.
- No `.verstak/notes`, no UUID note entities, no second truth separate from the
`.md` file.
## Minimal Infrastructure Changes
1. Add `openProviders` to SDK manifest/schema/types.
2. Add `openProviders` to desktop plugin manifest structs and contribution
registry.
3. Add `OpenResourceRequest` and provider support match types.
4. Add Workbench/provider selection service with deterministic rules.
5. Add user preferences for `defaultTextEditorProvider`,
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
6. Add frontend host plumbing so Workbench can mount the selected plugin
component.
7. Fix `verstak-sdk/schemas/manifest.json` permissions enum for `files.*`.
## Proposed Milestone 6b Scope
In scope:
- Contribution/types/schema support for `openProviders`.
- Workbench `openResource`/`editResource` routing API.
- Provider selection with notes context and user preferences.
- Minimal host tab/slot for provider component mounting.
- Diagnostic provider plugin contribution sufficient to prove routing.
- Tests for routing, provider selection, disabled fallback, and notes-context
markdown.
Out of scope:
- Full Files UI feature set.
- Full Notes UI feature set.
- Full editor implementation.
- Real default editor plugin (Milestone 6c).
- Files plugin open/edit integration.
- Notes plugin open/edit integration.
- Hardcoded core Markdown editor.
- Watcher/sync/binary streaming/external editor.
- Sidecar/security boundary.
- Large rewrite.
## 6b-fix: Infrastructure Gaps Closed
Post-review fixes before Milestone 6c:
- Added `openProviders` to `verstak-sdk/schemas/contributions.json` contribution points.
- Added provider matching tests: text/markdown preference, MIME matching, extension
case-insensitivity, multiple supports entries, kind mismatch.
- Added disabled provider exclusion test in `api/app_test.go`.
- Fixed SDK `build.sh`/`test.sh` to detect incomplete `node_modules`.
- Documented disabled provider lifecycle: contributions remain in registry until
ReloadPlugins; `activeOpenProviders()` filters disabled/unloaded at request time.
- Installed Playwright browsers for E2E tests.

View File

@ -1,65 +0,0 @@
# Milestone 6c — Default Editor Plugin
## Goal
Create the official Default Editor Plugin as an openProvider for text, generic
markdown, and notes-context markdown files. Core desktop does not own or import
the editor component.
## What was built
### Plugin: `verstak.default-editor`
Location: `verstak-official-plugins/plugins/default-editor/`
**Manifest declares 3 openProviders:**
| Provider ID | Context | Extensions |
|-------------|---------|------------|
| `verstak.default-editor.text` | `generic-text` | `.txt`, `.log`, `.conf`, `.ini`, `.toml`, `.yaml`, `.yml`, `.json`, `.csv` |
| `verstak.default-editor.markdown` | `generic-markdown` | `.md`, `.markdown` |
| `verstak.default-editor.notes-markdown` | `notes-markdown` | `.md`, `.markdown` |
All providers use the same `DefaultEditor` component (unified, not 3 separate editors).
**Permissions:** `files.read`, `files.write`, `workbench.open`
**Capabilities required:** `verstak/core/files/v1`, `verstak/core/workbench/v1`
### Frontend component: `DefaultEditor`
- **Modes:** text (textarea), generic-markdown (editor + preview), notes-markdown (editor + preview + notes badge)
- **File loading:** `api.files.readText(path)` with loading/error states
- **Saving:** `api.files.writeText(path, content, { overwrite: true })` with dirty/saved/error states
- **Keyboard:** Ctrl+S / Cmd+S save, Tab indentation
- **Markdown preview:** Simple renderer (no raw HTML, no script injection)
- **Notes context:** Badge + info bar, no separate note entity, no `.verstak/notes`
## Verification
- `go test ./...` — PASS
- `go vet ./...` — PASS
- `npm run build` (frontend) — PASS
- `npm run test:e2e` — 28/28 PASS (20 existing + 8 new)
- `scripts/check.sh` (official-plugins) — PASS
- `scripts/build.sh` (official-plugins) — PASS
- SDK checks — PASS
## Manual testing
Until Files UI plugin exists, use platform-test diagnostics panel to manually
open files via workbench: click "Open Text Diagnostic", "Open Markdown Diagnostic",
or "Open Notes Diagnostic" buttons. These call `api.workbench.editResource` and
route through the default-editor provider.
## Deferred
- CodeMirror/Monaco editor
- Backlinks, internal link navigation
- Secret widgets
- Image asset pipeline
- Files UI plugin
- Notes UI plugin
- Watcher/sync
- External open
- Sidecar/security isolation

View File

@ -1,64 +0,0 @@
# Milestone 6d — Minimal Files Plugin
## Goal
Create a minimal Files plugin that shows vault files/folders and opens files
through Workbench openResource. No editor embedded.
## What was built
### Plugin: `verstak.files`
Location: `verstak-official-plugins/plugins/files/`
**Contributions:**
- views: `verstak.files.view``FilesView` component
- No sidebarItems — Files is not a global sidebar item
**Permissions:** `files.read`, `files.write`, `workbench.open`, `ui.register`
### Files View
- Root listing on mount
- Folder navigation (double-click)
- File open via `api.workbench.openResource()`
- Breadcrumb navigation
- Create folder/file buttons
- Refresh button
- Loading/error/empty states
- `.verstak` filtered out
### Provider priority
- default-editor: priority 50
- platform-test diagnostic: priority 10
- default-editor wins for normal file opens
### Bundle fix
Fixed missing opening quote in STYLES string (`.files-empty` → `'.files-empty'`).
Added automated bundle execution check to `scripts/check.sh`.
## 6d-hotfix
- Removed sidebarItems from Files plugin (Files is not a global sidebar item)
- Added `[frontend bundle execution]` check to `check.sh` — verifies all plugin
bundles parse via `new Function()` and register via `VerstakPluginRegister`
- Updated E2E tests: Files no longer expected in global sidebar
- Documented: sidebarItems are global shell navigation, not workspace template tabs
## Verification
- `go test ./...` — PASS
- `go vet ./...` — PASS
- `npm run build` — PASS
- `npm run test:e2e` — 34/34 PASS
- Official plugins — 3 plugins built, bundle execution check passes
- SDK — 11/11 tests pass
## Deferred
- Notes plugin, rename/move/trash UI, drag-and-drop, context menu,
watcher/inotify, sync, external open, binary streaming, sidecar/security,
workspace template host (Milestone 6d2)

View File

@ -1,39 +1,30 @@
# Notes/Files Plugin Architecture Plan # Notes/Files Plugin Architecture Plan
This document locks the Notes/Files/Open architecture for the next milestones. This document captures the architectural decisions for Notes, Files, and
Files Core Service was implemented in Milestone 6a; this document still does not Workbench routing. Most items described below are now implemented; this
start Notes UI, Notes plugin, Files UI plugin, editor plugin, watcher, sync, or document remains as a reference for the canonical data model and rules.
binary streaming.
## Current Readiness ## Current Readiness
The platform is ready for bundled plugin UI experiments. Files Core is available All key components are implemented:
as a safe vault-scoped text file API. Notes, Files UI, and editor/viewer UI still
need plugin-level implementations and host surfaces before real product use.
Already available: - Files Core API with safe vault-scoped text and binary file operations.
- Notes plugin as a semantic view over canonical `Notes/` Markdown files.
- Plugin discovery, lifecycle, settings, capabilities, bundled commands, and - Files UI plugin with folder navigation and Workbench-based file opening.
bundled frontend events. - Default Editor plugin providing text/markdown openProviders.
- Workspace lifecycle APIs for top-level physical folders under the vault root. - File Preview plugin for images.
- Plugin-owned internal storage directories: - Workbench open/edit routing with provider selection.
`.verstak/plugin-data/<pluginId>`, `.verstak/plugin-settings/<pluginId>`, and
`.verstak/plugin-cache/<pluginId>`.
- Contribution registry entries for `fileActions`, `noteActions`,
`contextMenuEntries`, `searchProviders`, `activityProviders`, and
`statusBarItems`.
Not available yet:
- Notes plugin/API as a semantic view over Markdown files.
- Files UI plugin.
- Editor/viewer plugin.
- Open/edit resource routing and provider selection.
- UI hosts for file actions, note actions, context menus, search providers, - UI hosts for file actions, note actions, context menus, search providers,
activity providers, or status bar items. activity providers, and status bar items.
- Watcher/indexer for external filesystem changes. - File watcher for external filesystem changes.
- Real plugin isolation. Current permission checks are contract/policy checks, - Trash plugin for restore and permanent delete.
not a security boundary for bundled frontend JavaScript.
Not yet available:
- Real plugin isolation (bundled plugins run in shared JS context;
sidecar/sandbox is a future milestone).
- Chunked streaming/large-file import (bounded to 2 MB text / 8 MB bytes).
- Production-grade auto-update and release channel.
## Canonical Notes Model ## Canonical Notes Model

View File

@ -477,8 +477,8 @@ contributions summary.
- All paths are canonical vault-relative slash paths. Backslashes, POSIX - All paths are canonical vault-relative slash paths. Backslashes, POSIX
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes, absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
symlink traversal, and public access to `.verstak/` are rejected. symlink traversal, and public access to `.verstak/` are rejected.
- `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any - `.verstak` is reserved case-insensitively in every path segment: its marker,
first path segment with that spelling are internal-only. sync snapshot, trash, and workspace identity are internal-only.
- `files.metadata` may report a final symlink as `type: "symlink"`, but - `files.metadata` may report a final symlink as `type: "symlink"`, but
`files.list` through a symlink directory and all read/write/move/trash `files.list` through a symlink directory and all read/write/move/trash
operations through symlinks are forbidden in Milestone 6a. operations through symlinks are forbidden in Milestone 6a.
@ -486,20 +486,39 @@ contributions summary.
`writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is `writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is
deferred. deferred.
- Live watcher refresh is active while Verstak is running and a vault is open. - Live watcher refresh is active while Verstak is running and a vault is open.
It performs an initial no-event snapshot, then publishes `file.changed` for It publishes `file.changed` as a UI hint and debounces a full core scanner;
external creates, updates, and deletes outside `.verstak/`. It does not keep a the persistent `.verstak/sync/snapshot.json` scanner, not the watcher, is the
persistent snapshot or report what changed while Verstak was closed. source of truth. It runs on open, before manual sync, after watcher events,
and therefore detects changes made while Verstak was closed. Internal paths,
trash, temporary files, and symlinks are excluded.
`sync` `sync`
- `sync.now()` pushes local operations, pulls remote operations, and returns - `sync.now()` scans local files, pulls/reconciles in `server_sequence` order,
`{ pushed, pulled, serverSequence, conflicts?, applyErrors? }`. then pushes local operations and returns `{ pushed, pulled, serverSequence,
conflicts? }`. A remote apply failure stops the batch immediately, keeps that
operation and later sequences unacknowledged, and retries it on the next run.
- `conflicts` is an array of server-reported sync conflicts. Conflict objects - `conflicts` is an array of server-reported sync conflicts. Conflict objects
may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional
server fields. The Sync plugin must show conflict details instead of only a server fields. The Sync plugin must show conflict details instead of only a
count, and it must not silently resolve or overwrite local data. count, and it must not silently resolve or overwrite local data.
- `applyErrors` lists local apply failures for pulled operations. These are - `sync.status()` includes `vaultId` (the paired remote scope) and
user-visible warnings and do not imply that sync was fully successful. `lastWarning` for unresolved scanner input. A first connection pulls before
bootstrap: an empty local snapshot never publishes deletes, and incompatible
local/remote content is an explicit conflict rather than an overwrite.
- File and folder operations come from the scanner. An external rename is
intentionally represented as delete + create in this milestone. Workspace
lifecycle is a separate core `workspace` entity (`create`, `rename`,
`trash`, `restore`) carrying the durable `workspaceId`; plugins never obtain
access to the nested workspace marker.
- File transport remains bounded: UTF-8 text uses the 2 MB Files API limit;
binary and larger regular files are staged privately, sent through Blob API,
and referenced by SHA-256/size rather than base64 in `payload_json`. Desktop
streams and verifies downloads before atomic apply. Pull is paginated and the
durable cursor advances only after every applied sequence. Files above the
configured blob limit or otherwise unsupported remain unresolved warnings.
Operation retention/checkpoints and synchronization of Secrets, plugin
settings, Todo, Journal, Activity, and Browser Inbox are future work.
- Transport push/pull uses bounded retry/backoff for transient HTTP/network - Transport push/pull uses bounded retry/backoff for transient HTTP/network
failures. Client/auth errors are not retried. failures. Client/auth errors are not retried.
@ -585,8 +604,8 @@ bundled runtime. Это реальный runtime contract для cooperative bun
| `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` | | `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` |
| `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` | | `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` | | `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
| `api.sync.now()` | ✅ Работает | Push/pull с bounded retry/backoff для transient HTTP/network failures | | `api.sync.now()` | ✅ Работает | Snapshot scan, строгий ordered pull/retry и bounded retry/backoff для transient HTTP/network failures |
| `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked state, lastError, unpushed count | | `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked, remote `vaultId`, lastError/lastWarning и unpushed count |
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance | | `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |
Ограничения: Ограничения:
@ -710,6 +729,17 @@ not-created ──CreateVault──▶ open ──CloseVault──▶ closed
| `internal/core/permissions/registry.go` | PermissionsRegistry | | `internal/core/permissions/registry.go` | PermissionsRegistry |
| `internal/core/events/bus.go` | EventBus | | `internal/core/events/bus.go` | EventBus |
| `internal/api/app.go` | Wails API, ReloadPlugins | | `internal/api/app.go` | Wails API, ReloadPlugins |
| `internal/core/files/` | Files Core API: safe path policy, text/binary read/write, move, trash |
| `internal/core/filewatcher/` | Live filesystem watcher for external changes |
| `internal/core/workbench/` | Workbench open/edit provider routing |
| `internal/core/workspace/` | Workspace manager: create, rename, trash, templates, identity |
| `internal/core/notes/` | Notes file utilities (title↔filename, conflict detection) |
| `internal/core/sync/` | Sync scanner, snapshot, reconciliation, operation recording |
| `internal/core/secrets/` | AES-GCM secret store |
| `internal/core/browserreceiver/` | Local HTTP receiver for browser extension |
| `internal/core/notifications/` | Native desktop notifications |
| `internal/core/hostname/` | Hostname normalization for browser domain activity |
| `internal/core/externalopen/` | Platform-specific external file/folder opening |
| `internal/core/vault/vault.go` | Vault service: CreateVault, OpenVault, CloseVault, ResolveSafePath, plugin namespace paths | | `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/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.go` | Plugin storage API: settings/data/cache JSON with namespace isolation |

View File

@ -1,341 +0,0 @@
# Native Notifications, Tray, and Public README Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver native Todo reminders and Windows/Linux tray operation, then publish updated alpha artifacts and public README documentation with real application screenshots.
**Architecture:** Desktop core stores and delivers plugin-owned notification schedules and controls the tray lifecycle. Todo uses the public plugin API to replace its desired reminders. A small core tray adapter keeps the Wails process alive after window close. Documentation uses the supplied English and Russian README sources plus screenshots from an actual test vault.
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `getlantern/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
## Global constraints
- Support Windows and Linux only; no background daemon after an explicit Quit.
- Closing a window hides it; only tray **Quit** ends the process.
- `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
- Plugins cannot receive Wails runtime access.
- Keep Wails generated bindings out of commits.
- Commit each independent change and immediately push it to GitHub and `mirror`.
---
### Task 1: Persisted notification scheduler
**Files:**
- Create: `internal/core/notifications/manager.go`
- Create: `internal/core/notifications/store.go`
- Create: `internal/core/notifications/manager_test.go`
**Interfaces:**
- `type Request struct { ID, DueAt, Title, Body string }`
- `type Item struct { PluginID, ID, DueAt, Title, Body, SentForDueAt string }`
- `type Sender interface { Send(context.Context, Item) error }`
- `Manager.Replace(pluginID string, requests []Request) error`, `Clear(pluginID string) error`, `Start(context.Context)`, and `Stop()`.
- Persistent state: `<vault>/.verstak/notifications/schedules.json`.
- [ ] **Step 1: Add failing scheduler tests**
```go
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
sender := &fakeSender{err: errors.New("unavailable")}
m := newTestManager(t, sender, time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))
requireNoError(t, m.Replace("verstak.todo", []Request{{ID: "todo-1", DueAt: "2026-07-14T11:00:00Z", Title: "Reminder"}}))
m.Tick(context.Background())
if sender.calls != 1 || m.Items()[0].SentForDueAt != "" { t.Fatal("failed send was acknowledged") }
sender.err = nil
m.Tick(context.Background())
m.Tick(context.Background())
if sender.calls != 2 || m.Items()[0].SentForDueAt != "2026-07-14T11:00:00Z" { t.Fatal("delivery was not exactly once") }
}
```
Also test stale records are removed by `Replace`, changing `DueAt` rearms a record, state reloads from disk, and a past-due unsent item is sent once.
- [ ] **Step 2: Confirm red**
Run: `GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications -count=1`
Expected: fails because the package does not exist.
- [ ] **Step 3: Implement the minimal manager**
Use one mutex, injected clock/sender, an atomic same-directory temp-file rename, and a 30-second ticker. Preserve `SentForDueAt` only if the requested due time is unchanged. Validate non-empty unique IDs, RFC3339 UTC dates, at most 500 requests, and bounded text. Mark sent only after `Sender.Send` succeeds.
- [ ] **Step 4: Verify and commit**
Run:
```bash
gofmt -w internal/core/notifications/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications ./internal/core/... -count=1
git diff --check
```
Commit/push:
```bash
git add internal/core/notifications
git commit -m "feat: add persisted notification scheduler"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 2: Permission-gated core/plugin notification API
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `internal/core/permissions/registry.go`
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/tests/plugin-api-contributions-test.mjs`
**Interfaces:**
- Core capability: `verstak/core/notifications/v1`.
- Permission: `notifications.schedule`, non-dangerous.
- Bound methods: `ReplacePluginNotifications(pluginID string, items []notifications.Request) string` and `ClearPluginNotifications(pluginID string) string`.
- Plugin methods: `api.notifications.replace(items)` and `api.notifications.clear()`.
- [ ] **Step 1: Add failing backend and host-API tests**
```go
func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) {
app, scheduler := newNotificationTestApp(t, manifestWithoutNotificationPermission)
if got := app.ReplacePluginNotifications("example", []notifications.Request{{ID: "r", DueAt: "2026-07-14T10:00:00Z", Title: "R"}}); got == "" {
t.Fatal("missing permission was accepted")
}
if scheduler.replaceCalls != 0 { t.Fatalf("replace calls = %d", scheduler.replaceCalls) }
}
```
The JS test mocks `App.ReplacePluginNotifications`, calls `api.notifications.replace`, and asserts both plugin ID forwarding and a namespaced rejection for a non-empty backend error.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestReplacePluginNotificationsRequiresCapabilityAndPermission -count=1
node frontend/tests/plugin-api-contributions-test.mjs
```
Expected: missing API methods.
- [ ] **Step 3: Implement guarded bridge**
Register the capability in `main.go`, construct the scheduler, and guard bound calls with both existing access helpers before forwarding. Add `notifications.schedule` to the registry. Add the public host methods using `callBackendErrorString`; do not expose `window.runtime` to plugins.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
node frontend/tests/plugin-api-contributions-test.mjs
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go frontend/src/lib/plugin-host/VerstakPluginAPI.js frontend/tests/plugin-api-contributions-test.mjs
git commit -m "feat: expose scheduled notifications to plugins"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 3: Todo reminder synchronization and Wails lifecycle
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `../verstak-official-plugins/plugins/todo/plugin.json`
- Modify: `../verstak-official-plugins/plugins/todo/frontend/src/index.js`
- Modify: `../verstak-official-plugins/plugins/todo/locales/en.json`
- Modify: `../verstak-official-plugins/plugins/todo/locales/ru.json`
- Modify: `../verstak-official-plugins/scripts/smoke-todo-plugin.js`
**Interfaces:**
- `App.DomReady(ctx)` initializes Wails notifications before starting the scheduler.
- `App.Shutdown(ctx)` stops it before Wails cleanup.
- Todo replaces the complete reminder list only after Todo persistence succeeds and after Todo data loads.
- [ ] **Step 1: Add failing lifecycle and Todo smoke tests**
The App test uses function variables for Wails calls and proves initialize → start and stop → cleanup order. The Todo smoke mock records `notifications.replace`; it asserts one open reminder request with the Todo ID and a later empty replacement after completion or deletion.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run 'TestDomReadyInitializesNotificationsBeforeScheduler|TestShutdownStopsNotifications' -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js
```
Expected: lifecycle methods and schedule calls are absent.
- [ ] **Step 3: Implement lifecycle and Todo desired state**
Use `runtime.InitializeNotifications`, `runtime.SendNotification`, and `runtime.CleanupNotifications` only in core. Register `OnDomReady`/ `OnShutdown` in Wails options. Todo maps open valid `reminderAt` values to ISO UTC using `new Date(todo.reminderAt).toISOString()`, localizes title/body, and calls `api.notifications.replace`. A scheduling failure reports status but never rolls back already-saved Todo data.
- [ ] **Step 4: Verify and commit both repositories**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js && ./scripts/check.sh
```
```bash
cd ../verstak-desktop
git add main.go internal/api/app.go internal/api/app_test.go
git commit -m "feat: start native notifications with desktop app"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
cd ../verstak-official-plugins
git add plugins/todo scripts/smoke-todo-plugin.js
git commit -m "feat: schedule native Todo reminders"
git push https://github.com/mirivlad/verstak-official-plugins.git main
git push mirror main
```
### Task 4: Windows/Linux tray, close policy, and single instance
**Files:**
- Create: `internal/shell/tray/controller.go`
- Create: `internal/shell/tray/controller_test.go`
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `go.mod`
- Modify: `go.sum`
**Interfaces:**
- `tray.Start(icon []byte, actions Actions) error`, with `Actions.Show` and `Actions.Quit`.
- `App.BeforeClose(ctx) bool` hides and returns true unless `App.Quit()` made shutdown explicit.
- `App.ShowWindow()` reveals the existing window.
- [ ] **Step 1: Add failing close-policy and tray action tests**
```go
func TestBeforeCloseHidesWindowUntilExplicitQuit(t *testing.T) {
app, window := newWindowTestApp(t)
if prevent := app.BeforeClose(context.Background()); !prevent || window.hideCalls != 1 { t.Fatal("ordinary close must hide") }
app.Quit()
if prevent := app.BeforeClose(context.Background()); prevent { t.Fatal("explicit quit was prevented") }
}
```
Use a fake tray adapter to assert exactly two labels, **Show Verstak** and **Quit**, and one callback invocation per click.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestBeforeCloseHidesWindowUntilExplicitQuit -count=1
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray -count=1
```
Expected: absent packages/methods.
- [ ] **Step 3: Implement adapter and Wails wiring**
Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonblocking `systray.Register`, sets compact source-controlled PNG bytes derived from the existing tracked logo, and starts goroutines for the two click channels. `main.go` registers `OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls `app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/shell/tray/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
./scripts/build.sh
ldd build/bin/verstak-desktop | grep -E 'ayatana-appindicator|appindicator'
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/shell/tray go.mod go.sum
git commit -m "feat: keep desktop app in system tray"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 5: Packaging and public README screenshots
**Files:**
- Modify: `packaging/deb/control`
- Modify: `scripts/build.sh`
- Modify: `scripts/package-appimage.sh`
- Modify: `scripts/test-package-formats.sh`
- Modify: `README.md`
- Create: `README.ru.md`
- Create: `docs/screenshots/overview.png`
- Create: `docs/screenshots/workspace-files-notes.png`
- Create: `docs/screenshots/activity-journal.png`
- [ ] **Step 1: Add failing package-contract checks**
Add assertions for `libayatana-appindicator3-dev` in the Linux build guidance, `libayatana-appindicator3-1` in Debian dependencies, and `ayatana-appindicator` in the AppImage packing verification.
- [ ] **Step 2: Confirm red**
Run: `./scripts/test-package-formats.sh`
Expected: the first tray dependency assertion fails.
- [ ] **Step 3: Implement portable package support**
Declare the Debian runtime dependency. Make the Linux build error name the appindicator development header. Require the dynamically discovered appindicator library to appear in AppDir after the existing `ldd` traversal. Keep the Windows system-WebView2 policy unchanged.
- [ ] **Step 4: Install the supplied public README sources**
Replace this repository's `README.md` with the supplied English source at the workspace root and add the supplied Russian source as `README.ru.md`. Add an image strip after “What is Verstak?” / “Что такое Верстак?” that links to three tracked PNG screenshots and uses matching localized alt text.
- [ ] **Step 5: Produce real screenshots**
Build and run the desktop app against `/home/mirivlad/Nextcloud/Verstak/VerstakVault` on the active X display. Capture:
1. Overview showing recent work and entry points;
2. a populated workspace Files/Notes view;
3. Activity with a suggested session and its Journal review.
Use `scrot` or ImageMagick `import`, crop only surrounding desktop chrome, inspect every PNG visually, and do not add generated mockups. Remove all non-user-safe text from the test vault before capture.
- [ ] **Step 6: Verify documentation and package contracts, commit, push**
```bash
./scripts/test-package-formats.sh
git diff --check
test -s docs/screenshots/overview.png
test -s docs/screenshots/workspace-files-notes.png
test -s docs/screenshots/activity-journal.png
```
```bash
git add packaging/deb/control scripts/build.sh scripts/package-appimage.sh scripts/test-package-formats.sh README.md README.ru.md docs/screenshots
git commit -m "docs: add public README and product screenshots"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 6: Complete verification and GitHub alpha releases
**Files:**
- No source changes expected unless verification exposes a defect.
- [ ] **Step 1: Run all checks**
```bash
GOCACHE=/tmp/verstak-go-cache ./scripts/test.sh
./scripts/check.sh
./scripts/test-package-formats.sh
./scripts/test-build-windows.sh
cd ../verstak-official-plugins && ./scripts/check.sh && ./scripts/test-package-portable.sh && ./scripts/test-publish-github-release.sh
```
- [ ] **Step 2: Build and checksum both alpha releases**
```bash
cd ../verstak-official-plugins && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
cd ../verstak-desktop && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
```
- [ ] **Step 3: Publish and inspect releases**
```bash
cd ../verstak-official-plugins && ./scripts/publish-github-release.sh v0.1.0-alpha.2
cd ../verstak-desktop && ./scripts/publish-github-release.sh v0.1.0-alpha.2
gh release view v0.1.0-alpha.2 -R mirivlad/verstak-official-plugins
gh release view v0.1.0-alpha.2 -R mirivlad/verstak
```
The final report distinguishes automated verification from the two required real desktop manual checks: tray close/show/quit and a near-future Todo notification while the window is hidden.

View File

@ -1,174 +0,0 @@
# Native Notifications and System Tray Design
**Status:** approved for implementation on 2026-07-14
## Goal
Deliver Todo reminders as native notifications on Windows and Linux, and keep
Verstak running in the system tray when its main window is closed. The feature
must work in the portable Windows archive, Debian package, and AppImage.
## Scope
- The desktop core owns notification delivery, scheduling, and tray lifetime.
- `verstak.todo` owns the Todo-specific reminder policy and text.
- No new official notifications plugin is introduced. A dynamic plugin cannot
call Wails directly, so it cannot be the native-notification transport.
- macOS is out of scope for this alpha. The interfaces remain platform-neutral
where that costs nothing.
## Tray behavior
On Windows and Linux, a tray icon is registered before the Wails event loop
starts. Its menu contains exactly two actions:
1. **Show Verstak** — shows and focuses the existing main window.
2. **Quit** — exits the process deliberately.
Closing the main window with its window-manager close control hides the window
and keeps the process, plugins, local browser receiver, and reminder scheduler
alive. It does not terminate the application. The quit action temporarily
allows the close lifecycle to finish and then exits normally.
The app has a single-instance lock. If a user launches the executable while an
instance is hidden in the tray, the existing instance shows its window instead
of creating a second process.
The implementation uses `github.com/getlantern/systray` through a small
`internal/shell/tray` adapter. It uses `Register`, rather than its blocking
`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived
from the tracked project logo is encoded in the tray package, so a clean build
does not depend on ignored Wails-generated icon files. The library accepts PNG
icon bytes on both target platforms.
## Notification capability and permission
The core registers the capability:
```
verstak/core/notifications/v1
```
Plugins that use it must both require that capability and declare the
`notifications.schedule` permission. The plugin-host API exposes only two
operations within the calling plugin namespace:
```
api.notifications.replace(items)
api.notifications.clear()
```
`replace` is an atomic desired-state replacement, not an append operation. An
item contains a plugin-local stable `id`, an ISO-8601 UTC `dueAt`, a title, and
a body. The core supplies the plugin ID and rejects calls from disabled,
missing-permission, or undeclared-capability plugins. It also validates empty
IDs, duplicate IDs, invalid timestamps, and unsafe oversized text.
No plugin can send arbitrary immediate native notifications or address another
plugin's schedules in this alpha.
## Scheduler and persistence
`internal/core/notifications` persists one canonical schedule file at:
```
<vault>/.verstak/notifications/schedules.json
```
Each record contains `{pluginId, id, dueAt, title, body, sentForDueAt}`.
The composite `(pluginId, id)` is unique. Replacing an item with the same due
time preserves `sentForDueAt`; changing `dueAt` clears it. Replacing a plugin's
list removes its stale records. This provides deterministic cancellation for
completed, deleted, and rescheduled Todos.
The manager starts after Wails reaches `OnDomReady`, initializes Wails native
notifications, and evaluates the persisted schedule immediately and then at
least every 30 seconds. A sender is injected behind an interface for unit
tests. After a successful delivery, the manager atomically records
`sentForDueAt`. A delivery error leaves the schedule pending and is logged for
a later retry.
An expired record that has not been sent is delivered once after the next app
start. A record already sent for its current due time is never sent again.
Completely quitting Verstak stops the scheduler: no separate daemon or OS
background service is added. Hiding the window in the tray does **not** stop it.
The core calls `CleanupNotifications` during shutdown, including on Linux where
it releases the D-Bus connection.
## Todo behavior
`verstak.todo` adds the core notifications capability to `requires` and adds
the `notifications.schedule` permission. After every successful Todo storage
write, it derives the complete desired reminder list:
- include only open Todos with a valid `reminderAt`;
- use the Todo ID as the stable notification ID;
- convert local `datetime-local` input to an ISO-8601 UTC instant;
- use the Todo title in the notification body and locale-aware reminder text;
- call `api.notifications.replace` with the full list.
The same replacement runs after loading persisted Todos, so a transient
schedule-write failure repairs itself next time the Todo view is opened. A
schedule API failure does not roll back Todo data; the UI reports the failure
instead. The existing in-view overdue/reminder badge remains useful context and
is not removed.
## Packaging
`getlantern/systray` requires CGO. The Windows release build already uses
`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the
tray dependency included. Linux build instructions add
`libayatana-appindicator3-dev`. The Debian package declares the corresponding
runtime dependency `libayatana-appindicator3-1`.
The existing AppImage packager traverses `ldd` for the desktop executable and
copies non-glibc runtime libraries. Its verification is extended to prove that
the appindicator library is present in the AppDir when the tray implementation
is compiled in.
## Public README and product screenshots
The workspace-root `README.md` and `README.ru.md` supplied by the maintainer
become the public repository documents: the English source replaces this
repository's `README.md`, and the Russian source is committed as
`README.ru.md`. Their existing language links and public-release instructions
are retained.
Three screenshots are captured from the real desktop application using the
test vault, then committed under `docs/screenshots/`:
1. `overview.png` — returning to recent work and useful next actions;
2. `workspace-files-notes.png` — ordinary vault files and Markdown notes in a
workspace;
3. `activity-journal.png` — review of a factual activity session as a Journal
entry.
Both README variants include the same three images with localized alt text.
They are factual UI captures, not generated illustrative mockups. Capture data
must be limited to the disposable test vault and inspected before commit so no
credentials or personal content are published.
## Test and manual verification
Automated tests cover:
- schedule replacement, cancellation, rescheduling, persistence, one-time
overdue delivery, failed-send retry, and permission/capability rejection;
- Todo desired-list derivation and calls after create/edit/status/delete;
- close policy: ordinary close hides, explicit quit permits shutdown;
- tray controller action wiring and second-instance window reveal;
- Linux/Windows build scripts and package dependency expectations.
Manual smoke tests are required because neither unit tests nor Playwright can
assert a real desktop notification area or OS toast:
1. On Linux and Windows, start Verstak, close its window, use the tray menu to
reveal it, and use **Quit** to terminate it.
2. Set a Todo reminder for a near future time, hide the window in the tray, and
observe one native notification.
3. Quit before a future reminder, relaunch after it expires, and observe one
overdue notification with no duplicate on the next scheduler scan.
4. Inspect all three README screenshots at their committed size and confirm
that they show only intended test-vault data and explain the feature named
in their localized captions.

View File

@ -99,7 +99,7 @@ test.describe('Command Palette', () => {
test('runs sync workflow commands', async ({ page }) => { test('runs sync workflow commands', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret'); const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret', '');
if (err) throw new Error(err); if (err) throw new Error(err);
}); });

View File

@ -102,7 +102,7 @@ test.describe('D: Plugin API bridge', () => {
const api = window.createPluginAPI('verstak.sync'); const api = window.createPluginAPI('verstak.sync');
const initial = await api.sync.status(); const initial = await api.sync.status();
await api.sync.testConnection('https://sync.example.test', 'alice', 'secret'); await api.sync.testConnection('https://sync.example.test', 'alice', 'secret');
await api.sync.configure('https://sync.example.test', 'alice', 'secret'); await api.sync.configure('https://sync.example.test', 'alice', 'secret', 'existing-remote-vault');
await api.sync.setInterval(15); await api.sync.setInterval(15);
const configured = await api.sync.status(); const configured = await api.sync.status();
const syncNow = await api.sync.now(); const syncNow = await api.sync.now();
@ -117,6 +117,8 @@ test.describe('D: Plugin API bridge', () => {
expect(result.initial.statusLabel).toBe('disabled'); expect(result.initial.statusLabel).toBe('disabled');
expect(result.configured.configured).toBe(true); expect(result.configured.configured).toBe(true);
expect(result.configured.serverUrl).toBe('https://sync.example.test'); expect(result.configured.serverUrl).toBe('https://sync.example.test');
expect(result.configured.vaultId).toBe('existing-remote-vault');
expect(result.configured.lastWarning).toBe('');
expect(result.configured.syncInterval).toBe(15); expect(result.configured.syncInterval).toBe(15);
expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 }); expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 });
expect(result.reset.configured).toBe(false); expect(result.reset.configured).toBe(false);

View File

@ -160,6 +160,15 @@ export default {
'workspaceTree.createError': 'Could not create the Deal. Please try again.', 'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.', 'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.', 'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'workspaceTree.parentFolder': 'Parent folder',
'workspaceTree.rootFolder': '(vault root)',
'workspaceTree.folderTitle': 'Folders',
'workspaceTree.folderEmpty': 'No Deals here',
'workspaceTree.createFolder': 'Create Folder',
'workspaceTree.folderNamePlaceholder': 'Folder name',
'workspaceTree.folderIcon': 'Icon',
'workspaceTree.folderColor': 'Color',
'workspaceTree.folderCreateError': 'Could not create the folder. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.', 'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.', 'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.', 'pluginManager.enableError': 'Could not enable the plugin. Please try again.',

View File

@ -160,6 +160,15 @@ export default {
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.', 'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.', 'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.', 'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'workspaceTree.parentFolder': 'Родительская папка',
'workspaceTree.rootFolder': '(корень vault)',
'workspaceTree.folderTitle': 'Папки',
'workspaceTree.folderEmpty': 'Здесь нет Дел',
'workspaceTree.createFolder': 'Создать папку',
'workspaceTree.folderNamePlaceholder': 'Название папки',
'workspaceTree.folderIcon': 'Иконка',
'workspaceTree.folderColor': 'Цвет',
'workspaceTree.folderCreateError': 'Не удалось создать папку. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.', 'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.', 'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.', 'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',

View File

@ -468,10 +468,10 @@ export function createPluginAPI(pluginId) {
return App.PluginSyncStatus(pluginId); return App.PluginSyncStatus(pluginId);
}); });
}, },
configure: function(serverURL, username, password) { configure: function(serverURL, username, password, vaultId) {
assertActive('sync.configure'); assertActive('sync.configure');
return callBackendErrorString(pluginId, 'sync.configure', function() { return callBackendErrorString(pluginId, 'sync.configure', function() {
return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || ''); return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || '', vaultId || '');
}); });
}, },
disconnect: function() { disconnect: function() {
@ -584,6 +584,48 @@ export function createPluginAPI(pluginId) {
} }
}, },
workspaces: {
list: async function() {
assertActive('workspaces.list');
return callBackend(pluginId, 'workspaces.list', () => App.ListWorkspaces());
},
getCurrent: async function() {
assertActive('workspaces.getCurrent');
return callBackend(pluginId, 'workspaces.getCurrent', () => App.GetCurrentWorkspace());
},
getTree: async function() {
assertActive('workspaces.getTree');
return callBackend(pluginId, 'workspaces.getTree', () => App.GetWorkspaceTree());
},
select: async function(path) {
assertActive('workspaces.select');
return callBackendErrorString(pluginId, 'workspaces.select', () => App.SetCurrentWorkspace(path));
},
create: async function(path, templateId) {
assertActive('workspaces.create');
return callBackend(pluginId, 'workspaces.create', () => App.CreateWorkspace(path, templateId || 'default'));
},
trash: async function(path) {
assertActive('workspaces.trash');
return callBackend(pluginId, 'workspaces.trash', () => App.TrashWorkspace(path));
},
move: async function(id, newParentId) {
assertActive('workspaces.move');
return callBackendErrorString(pluginId, 'workspaces.move', () => App.MoveWorkspace(id, newParentId));
}
},
folders: {
getMetadata: async function(path) {
assertActive('folders.getMetadata');
return callBackend(pluginId, 'folders.getMetadata', () => App.GetFolderMetadata(path));
},
setMetadata: async function(path, meta) {
assertActive('folders.setMetadata');
return callBackendErrorString(pluginId, 'folders.setMetadata', () => App.SetFolderMetadata(path, meta));
}
},
dispose: function() { dispose: function() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;

View File

@ -2,6 +2,7 @@
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte'; import WorkspaceTree from './WorkspaceTree.svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import GlobalSearch from './GlobalSearch.svelte'; import GlobalSearch from './GlobalSearch.svelte';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js'; import { debug } from '../log/debug.js';
@ -18,6 +19,7 @@
let plugins = []; let plugins = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' }; let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let sidebarItems = []; let sidebarItems = [];
let workspaceTreeProvider = null;
let errorMessage = ''; let errorMessage = '';
let locale = i18n.getLocale(); let locale = i18n.getLocale();
let unsubscribeLocale = null; let unsubscribeLocale = null;
@ -59,6 +61,19 @@
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability'; return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
}); });
sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100)); sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100));
// Check for workspaceTree contribution
const wtContrib = (localizedContributions.workspaceTree || null);
if (wtContrib && wtContrib.pluginId && wtContrib.component) {
const wtPlugin = plugins.find(p => p.manifest?.id === wtContrib.pluginId);
if (wtPlugin && wtPlugin.status !== 'disabled' && wtPlugin.status !== 'failed' && wtPlugin.status !== 'incompatible' && wtPlugin.status !== 'missing-required-capability') {
workspaceTreeProvider = { pluginId: wtContrib.pluginId, component: wtContrib.component };
} else {
workspaceTreeProvider = null;
}
} else {
workspaceTreeProvider = null;
}
debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length); debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length);
flog('onMount: sidebarItems=' + sidebarItems.length); flog('onMount: sidebarItems=' + sidebarItems.length);
} catch (e) { } catch (e) {
@ -123,8 +138,12 @@
{/if} {/if}
{#if vaultOpen} {#if vaultOpen}
{#if workspaceTreeProvider}
<PluginBundleHost pluginId={workspaceTreeProvider.pluginId} componentId={workspaceTreeProvider.component} />
{:else}
<WorkspaceTree /> <WorkspaceTree />
{/if} {/if}
{/if}
<div class="sidebar-footer"> <div class="sidebar-footer">
{#if errorMessage} {#if errorMessage}

View File

@ -13,6 +13,7 @@
let loading = true; let loading = true;
let localError = ''; let localError = '';
let workspaces = []; let workspaces = [];
let treeNodes = [];
let currentWorkspaceId = ''; let currentWorkspaceId = '';
let showCreate = false; let showCreate = false;
let newWorkspaceName = ''; let newWorkspaceName = '';
@ -25,11 +26,12 @@
let templateWarning = null; let templateWarning = null;
let templatesLoading = false; let templatesLoading = false;
let creating = false; let creating = false;
let renamingId = '';
let renameValue = '';
let busyId = ''; let busyId = '';
let expandedFolders = {};
let parentFolderPath = '';
let locale = i18n.getLocale(); let locale = i18n.getLocale();
let unsubscribeLocale = null; let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => { $: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale; void activeLocale;
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
@ -81,45 +83,20 @@
function templateToolState(pluginId, plugins, capabilities, names, translate) { function templateToolState(pluginId, plugins, capabilities, names, translate) {
const plugin = plugins[pluginId]; const plugin = plugins[pluginId];
if (!plugin) { if (!plugin) {
return { return { pluginId, name: toolLabel(pluginId, names), tabs: [], status: 'unavailable', reason: translate('workspaceTree.templateMissingPlugin') };
pluginId,
name: toolLabel(pluginId, names),
tabs: [],
status: 'unavailable',
reason: translate('workspaceTree.templateMissingPlugin'),
};
} }
const manifest = plugin.manifest || {}; const manifest = plugin.manifest || {};
const tabs = Array.isArray(manifest.contributes?.workspaceItems) const tabs = Array.isArray(manifest.contributes?.workspaceItems) ? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean) : [];
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
: [];
const pluginStatus = String(plugin.status || '').toLowerCase(); const pluginStatus = String(plugin.status || '').toLowerCase();
const missingCapability = Array.isArray(manifest.requires) const missingCapability = Array.isArray(manifest.requires) && manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
let status = 'available'; let status = 'available';
let reason = translate('workspaceTree.templateAvailable'); let reason = translate('workspaceTree.templateAvailable');
if (!plugin.enabled || pluginStatus === 'disabled') { status = 'unavailable'; reason = translate('workspaceTree.templatePluginDisabled'); }
if (!plugin.enabled || pluginStatus === 'disabled') { else if (pluginStatus === 'missing-required-capability' || missingCapability) { status = 'unavailable'; reason = translate('workspaceTree.templateCapabilityUnavailable'); }
status = 'unavailable'; else if (pluginStatus === 'incompatible') { status = 'unavailable'; reason = translate('workspaceTree.templateIncompatible'); }
reason = translate('workspaceTree.templatePluginDisabled'); else if (pluginStatus === 'failed') { status = 'unavailable'; reason = translate('workspaceTree.templateLoadFailed'); }
} else if (pluginStatus === 'missing-required-capability' || missingCapability) { else if (pluginStatus === 'degraded') { status = 'limited'; reason = translate('workspaceTree.templateLimited'); }
status = 'unavailable'; else if (pluginStatus !== 'loaded') { status = 'unavailable'; reason = translate('workspaceTree.templateNotReady'); }
reason = translate('workspaceTree.templateCapabilityUnavailable');
} else if (pluginStatus === 'incompatible') {
status = 'unavailable';
reason = translate('workspaceTree.templateIncompatible');
} else if (pluginStatus === 'failed') {
status = 'unavailable';
reason = translate('workspaceTree.templateLoadFailed');
} else if (pluginStatus === 'degraded') {
status = 'limited';
reason = translate('workspaceTree.templateLimited');
} else if (pluginStatus !== 'loaded') {
status = 'unavailable';
reason = translate('workspaceTree.templateNotReady');
}
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason }; return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
} }
@ -132,59 +109,129 @@
App.GetCapabilities ? App.GetCapabilities() : [], App.GetCapabilities ? App.GetCapabilities() : [],
]); ]);
const [list, err] = resultOrError(templates, []); const [list, err] = resultOrError(templates, []);
if (err) { if (err) { createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates.', err); workspaceTemplates = []; return; }
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err);
workspaceTemplates = [];
return;
}
workspaceTemplates = Array.isArray(list) ? list : []; workspaceTemplates = Array.isArray(list) ? list : [];
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => ( await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})));
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)); const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
templatePluginNames = localizedPlugins.reduce((names, plugin) => { templatePluginNames = localizedPlugins.reduce((names, plugin) => { const id = plugin?.manifest?.id; const name = plugin?.manifest?.name; if (id && name) names[id] = name; return names; }, {});
const id = plugin?.manifest?.id; templatePlugins = localizedPlugins.reduce((result, plugin) => { const id = plugin?.manifest?.id; if (id) result[id] = plugin; return result; }, {});
const name = plugin?.manifest?.name;
if (id && name) names[id] = name;
return names;
}, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => {
const id = plugin?.manifest?.id;
if (id) result[id] = plugin;
return result;
}, {});
const [capabilityList] = resultOrError(capabilities, []); const [capabilityList] = resultOrError(capabilities, []);
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean)); templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) { if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) selectedTemplateId = workspaceTemplates[0]?.id || '';
selectedTemplateId = workspaceTemplates[0]?.id || ''; } catch (error) { createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates.', error); workspaceTemplates = []; }
} finally { templatesLoading = false; }
} catch (error) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error);
workspaceTemplates = [];
} finally {
templatesLoading = false;
}
} }
function wsName(workspace) { function wsName(workspace) {
return String(workspace?.name || workspace?.rootPath || ''); return String(workspace?.name || workspace?.path || '');
} }
function asNode(workspace, order) { function wsPath(workspace) {
const name = wsName(workspace); return String(workspace?.path || workspace?.rootPath || '');
return { }
id: name,
function buildTreeNodes(list) {
const nodes = [];
const nodeMap = {};
const folders = [];
// Collect all nodes: workspaces + folder nodes from GetTree
// First try the tree from GetTree if available
if (treeNodes.length > 0) {
const folderMap = {};
const wsMap = {};
for (const node of treeNodes) {
if (node.type === 'folder') {
folders.push(node);
folderMap[node.id] = node;
} else if (node.type === 'space') {
wsMap[node.id] = node;
}
}
// Match workspaces to their parent folders
const result = [];
for (const ws of list) {
const path = wsPath(ws);
const tn = wsMap[path];
result.push({
id: path,
type: 'space',
title: wsName(ws),
name: wsName(ws),
path: path,
rootPath: path,
parentId: tn?.parentId || null,
status: 'active',
order: tn?.order || 0,
workspace: ws,
});
}
for (const folder of folders) {
result.push({
id: folder.id,
type: 'folder',
title: folder.title,
name: folder.title,
path: folder.path || folder.id,
parentId: folder.parentId || null,
status: 'active',
order: folder.order || 0,
folder: folder,
});
}
return result;
}
// Fallback: build flat tree from workspace paths
const visibleFolders = new Set();
for (const ws of list) {
const path = wsPath(ws);
const parts = path.split('/');
const name = parts[parts.length - 1];
const parentId = parts.length > 1 ? parts.slice(0, -1).join('/') : null;
// Ensure all parent folders exist
if (parentId) {
let current = '';
for (let i = 0; i < parts.length - 1; i++) {
current = current ? current + '/' + parts[i] : parts[i];
if (!visibleFolders.has(current)) {
visibleFolders.add(current);
nodes.push({
id: current,
type: 'folder',
title: parts[i],
name: parts[i],
path: current,
parentId: i > 0 ? parts.slice(0, i).join('/') : null,
status: 'active',
order: 0,
});
}
}
}
nodes.push({
id: path,
type: 'space', type: 'space',
title: name, title: name,
name, name: name,
rootPath: workspace.rootPath || name, path: path,
rootPath: path,
parentId: parentId,
status: 'active', status: 'active',
order, order: nodes.length,
}; workspace: ws,
});
}
return nodes;
} }
function nodesForEvent() { function nodesForEvent() {
return workspaces.map(asNode); return workspaces.map((ws, i) => ({
id: wsPath(ws), type: 'space', title: wsName(ws),
name: wsName(ws), rootPath: wsPath(ws), status: 'active', order: i,
}));
} }
async function loadWorkspaces() { async function loadWorkspaces() {
@ -192,82 +239,92 @@
localError = ''; localError = '';
try { try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []); const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) { if (err) { localError = reportError('workspaceTree.loadError', 'Could not load Deals.', err); workspaces = []; }
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err); else { workspaces = list || []; }
workspaces = [];
} else { // Try to get tree nodes
workspaces = list || []; try {
const tree = await App.GetWorkspaceTree();
if (tree && tree.nodes) treeNodes = tree.nodes;
} catch { treeNodes = []; }
const allNodes = buildTreeNodes(workspaces);
if (!currentWorkspaceId) { if (!currentWorkspaceId) {
let currentWorkspace = null; let currentWorkspace = null;
try { try { currentWorkspace = await App.GetCurrentWorkspace(); } catch { currentWorkspace = null; }
currentWorkspace = await App.GetCurrentWorkspace(); const currentPath = currentWorkspace?.path || currentWorkspace?.rootPath || '';
} catch { if (allNodes.some(n => n.id === currentPath)) currentWorkspaceId = currentPath;
currentWorkspace = null; } else if (!allNodes.some(n => n.id === currentWorkspaceId)) {
}
const currentName = wsName(currentWorkspace);
if (workspaces.some((ws) => wsName(ws) === currentName)) {
currentWorkspaceId = currentName;
}
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = ''; currentWorkspaceId = '';
} }
activeWorkspaceId.set(currentWorkspaceId); activeWorkspaceId.set(currentWorkspaceId);
} workspaces = allNodes;
} catch (e) { } catch (e) { localError = reportError('workspaceTree.loadError', 'Could not load Deals.', e); }
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e);
}
loading = false; loading = false;
} }
async function selectWorkspace(workspace) { function getChildren(parentId) {
const id = wsName(workspace); const id = parentId || null;
const err = await App.SetCurrentWorkspace(id); return workspaces.filter(n => (n.parentId || null) === id).sort((a, b) => {
if (err) { if (a.type === 'folder' && b.type !== 'folder') return -1;
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err); if (a.type !== 'folder' && b.type === 'folder') return 1;
return; return (a.order || 0) - (b.order || 0) || a.title.localeCompare(b.title);
});
} }
function isExpanded(node) {
if (node.type !== 'folder') return false;
if (expandedFolders[node.id] !== undefined) return expandedFolders[node.id];
return true; // default expanded
}
function toggleFolder(node) {
expandedFolders[node.id] = !isExpanded(node);
expandedFolders = expandedFolders; // trigger reactivity
}
async function selectWorkspace(node) {
const id = node.id;
const err = await App.SetCurrentWorkspace(id);
if (err) { localError = reportError('workspaceTree.selectError', 'Could not select this Deal.', err); return; }
currentWorkspaceId = id; currentWorkspaceId = id;
activeWorkspaceId.set(id); activeWorkspaceId.set(id);
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', { window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: id, nodes: nodesForEvent() } detail: { workspaceName: id, workspacePath: id, nodes: nodesForEvent() }
})); }));
} }
function buildCreatePath() {
const name = newWorkspaceName.trim();
if (parentFolderPath) return parentFolderPath + '/' + name;
return name;
}
async function doCreate() { async function doCreate() {
const name = newWorkspaceName.trim(); const name = newWorkspaceName.trim();
if (!name) { if (!name) { createError = tr('workspaceTree.nameRequired'); return; }
createError = tr('workspaceTree.nameRequired'); if (!selectedTemplate) { createError = tr('workspaceTree.chooseTemplate'); return; }
return; const path = buildCreatePath();
}
if (!selectedTemplate) {
createError = tr('workspaceTree.chooseTemplate');
return;
}
const creationIssues = selectedTemplateIssues.map(tool => ({
pluginId: tool.pluginId,
name: tool.name,
reason: tool.reason,
}));
creating = true; creating = true;
createError = ''; createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null); const [, err] = resultOrError(await App.CreateWorkspace(path, selectedTemplate.id), null);
if (err) { if (err) { createError = reportError('workspaceTree.createError', 'Could not create the Deal.', err); creating = false; return; }
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err);
creating = false;
return;
}
showCreate = false; showCreate = false;
newWorkspaceName = ''; newWorkspaceName = '';
parentFolderPath = '';
creating = false; creating = false;
await loadWorkspaces(); await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name); const created = workspaces.find((ws) => ws.id === path);
if (created) await selectWorkspace(created); if (created) await selectWorkspace(created);
const creationIssues = selectedTemplateIssues.map(tool => ({ pluginId: tool.pluginId, name: tool.name, reason: tool.reason }));
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null; templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
} }
async function openCreateDialog() { async function openCreateDialog() {
showCreate = true; showCreate = true;
newWorkspaceName = ''; newWorkspaceName = '';
parentFolderPath = '';
createError = ''; createError = '';
await loadWorkspaceTemplates(); await loadWorkspaceTemplates();
} }
@ -276,64 +333,48 @@
if (creating) return; if (creating) return;
showCreate = false; showCreate = false;
newWorkspaceName = ''; newWorkspaceName = '';
parentFolderPath = '';
createError = ''; createError = '';
} }
function dismissTemplateWarning() { function dismissTemplateWarning() { templateWarning = null; }
templateWarning = null;
function startRename(node) {
busyId = node.id;
// For rename, we store the full path but edit just the name part
// Actually, let's keep rename simple: edit the last segment
} }
function startRename(workspace) { function cancelRename() { }
renamingId = wsName(workspace);
renameValue = renamingId;
localError = '';
}
function cancelRename() { async function commitRename() { }
renamingId = '';
renameValue = '';
}
async function commitRename(workspace) { async function trashWorkspace(node) {
const oldName = wsName(workspace); const path = node.id;
const newName = renameValue.trim(); busyId = path;
if (!newName || newName === oldName) { const [, err] = resultOrError(await App.TrashWorkspace(path), null);
cancelRename(); if (err) { localError = reportError('workspaceTree.trashError', 'Could not move to trash.', err); busyId = ''; return; }
return; if (currentWorkspaceId === path) currentWorkspaceId = '';
}
busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName);
if (err) {
localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err);
busyId = '';
return;
}
renamingId = '';
renameValue = '';
busyId = '';
currentWorkspaceId = newName;
await loadWorkspaces();
const renamed = workspaces.find((ws) => wsName(ws) === newName);
if (renamed) await selectWorkspace(renamed);
}
async function trashWorkspace(workspace) {
const name = wsName(workspace);
busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) {
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err);
busyId = '';
return;
}
if (currentWorkspaceId === name) currentWorkspaceId = '';
busyId = ''; busyId = '';
await loadWorkspaces(); await loadWorkspaces();
if (currentWorkspaceId) { if (currentWorkspaceId) {
const selected = workspaces.find((ws) => wsName(ws) === currentWorkspaceId); const selected = workspaces.find((ws) => ws.id === currentWorkspaceId);
if (selected) await selectWorkspace(selected); if (selected) await selectWorkspace(selected);
} }
} }
// Available parent folders for create dialog
$: parentFolderOptions = getFolderOptions();
function getFolderOptions() {
const opts = [{ id: '', name: tr('workspaceTree.rootFolder') || '(root)' }];
for (const node of workspaces) {
if (node.type === 'folder') {
opts.push({ id: node.id, name: node.id });
}
}
return opts;
}
</script> </script>
<div class="wt"> <div class="wt">
@ -349,34 +390,40 @@
{/if} {/if}
<div class="wt-list"> <div class="wt-list">
{#each workspaces as workspace (wsName(workspace))} {#each getChildren(null) as node (node.id)}
{@const id = wsName(workspace)} {#if node.type === 'folder'}
<div class="wt-node vt-list-row" class:selected={id === $activeWorkspaceId}> {@const expanded = isExpanded(node)}
<div class="wt-folder">
<div class="wt-row wt-folder-row" on:click={() => toggleFolder(node)}>
<span class="wt-chevron">{expanded ? '▾' : '▸'}</span>
<span class="wt-icon"><Icon name="folder" size={13} /></span>
<span class="wt-label wt-folder-label">{node.title}</span>
</div>
{#if expanded}
{#each getChildren(node.id) as child (child.id)}
<div class="wt-node vt-list-row" class:selected={child.id === $activeWorkspaceId} style="padding-left: 1.2rem;">
<div class="wt-row"> <div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span> <span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
{#if renamingId === id} <button class="wt-label" on:click={() => selectWorkspace(child)} type="button">{child.title}</button>
<input <button class="wt-icon-btn danger" on:click={() => trashWorkspace(child)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === child.id}>
class="wt-rename"
bind:value={renameValue}
disabled={busyId === id}
on:keydown={(e) => {
if (e.key === 'Enter') commitRename(workspace);
if (e.key === 'Escape') cancelRename();
}}
/>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>{tr('common.save')}</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
{:else}
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title={tr('workspaceTree.rename')} type="button" disabled={busyId === id}>
<Icon name="edit" size={12} />
</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
<Icon name="trash" size={12} /> <Icon name="trash" size={12} />
</button> </button>
</div>
</div>
{/each}
{/if} {/if}
</div> </div>
{:else}
<div class="wt-node vt-list-row" class:selected={node.id === $activeWorkspaceId}>
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
<button class="wt-label" on:click={() => selectWorkspace(node)} type="button">{node.title}</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(node)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === node.id}>
<Icon name="trash" size={12} />
</button>
</div> </div>
</div>
{/if}
{/each} {/each}
</div> </div>
@ -396,15 +443,21 @@
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}> <div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal"> <div class="workspace-create-modal">
<div class="workspace-create-header"> <div class="workspace-create-header">
<div> <div><h2>{tr('workspaceTree.new')}</h2></div>
<h2>{tr('workspaceTree.new')}</h2>
</div>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button> <button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
</div> </div>
<label class="workspace-create-field"> <label class="workspace-create-field">
<span>{tr('pluginCard.name')}</span> <span>{tr('pluginCard.name')}</span>
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} /> <input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
</label> </label>
<label class="workspace-create-field">
<span>{tr('workspaceTree.parentFolder') || 'Parent folder'}</span>
<select data-workspace-parent bind:value={parentFolderPath} disabled={creating}>
{#each parentFolderOptions as opt (opt.id)}
<option value={opt.id}>{opt.name}</option>
{/each}
</select>
</label>
<label class="workspace-create-field"> <label class="workspace-create-field">
<span>{tr('workspaceTree.template')}</span> <span>{tr('workspaceTree.template')}</span>
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}> <select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
@ -418,13 +471,7 @@
<p data-workspace-template-description>{selectedTemplate.description}</p> <p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools> <div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplateTools as tool (tool.pluginId)} {#each selectedTemplateTools as tool (tool.pluginId)}
<div <div class="workspace-template-tool" class:limited={tool.status === 'limited'} class:unavailable={tool.status === 'unavailable'} data-workspace-template-tool={tool.pluginId} data-template-tool-status={tool.status}>
class="workspace-template-tool"
class:limited={tool.status === 'limited'}
class:unavailable={tool.status === 'unavailable'}
data-workspace-template-tool={tool.pluginId}
data-template-tool-status={tool.status}
>
<span class="workspace-template-tool-name">{tool.name}</span> <span class="workspace-template-tool-name">{tool.name}</span>
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span> <span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
<span class="workspace-template-tool-reason">{tool.reason}</span> <span class="workspace-template-tool-reason">{tool.reason}</span>
@ -455,24 +502,23 @@
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; } .wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; }
.wt-btn { min-height: 1.55rem; background: transparent; border: 1px solid transparent; color: var(--vt-color-text-muted); cursor: pointer; font-size: 0.78rem; padding: 0.12rem 0.38rem; border-radius: var(--vt-radius-sm); } .wt-btn { min-height: 1.55rem; background: transparent; border: 1px solid transparent; color: var(--vt-color-text-muted); cursor: pointer; font-size: 0.78rem; padding: 0.12rem 0.38rem; border-radius: var(--vt-radius-sm); }
.wt-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); } .wt-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
.wt-always { opacity: 1; }
.wt-row:hover .wt-btn-small { opacity: 1; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: var(--vt-color-text-muted); } .wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: var(--vt-color-text-muted); }
.wt-error { color: var(--vt-color-danger); } .wt-error { color: var(--vt-color-danger); }
.wt-row { display: flex; align-items: center; gap: 0.45rem; padding: 0.18rem 0.45rem; min-height: 1.85rem; border-radius: var(--vt-radius-sm); } .wt-row { display: flex; align-items: center; gap: 0.45rem; padding: 0.18rem 0.45rem; min-height: 1.85rem; border-radius: var(--vt-radius-sm); }
.wt-row:hover { background: var(--vt-color-surface-hover); } .wt-row:hover { background: var(--vt-color-surface-hover); }
.wt-folder-row { cursor: pointer; }
.wt-folder-row:hover { color: var(--vt-color-accent); }
.wt-chevron { width: 0.8rem; font-size: 0.65rem; color: var(--vt-color-text-muted); flex-shrink: 0; user-select: none; }
.wt-folder-label { color: var(--vt-color-text-secondary); font-weight: 500; }
.wt-node.selected > .wt-row { background: var(--vt-color-surface-selected); box-shadow: inset 2px 0 0 var(--vt-color-accent); } .wt-node.selected > .wt-row { background: var(--vt-color-surface-selected); box-shadow: inset 2px 0 0 var(--vt-color-accent); }
.wt-icon { width: 0.95rem; height: 0.95rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--vt-color-text-muted); } .wt-icon { width: 0.95rem; height: 0.95rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--vt-color-text-muted); }
:global(.wt-node-icon) { display: block; } :global(.wt-node-icon) { display: block; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: var(--vt-color-text-secondary); font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0; border-radius: var(--vt-radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: var(--vt-color-text-secondary); font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0; border-radius: var(--vt-radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: var(--vt-color-accent); } .wt-label:hover { color: var(--vt-color-accent); }
.wt-icon-btn { width: 1.45rem; height: 1.45rem; min-height: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--vt-color-text-muted); opacity: 0.75; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); } .wt-icon-btn { width: 1.45rem; height: 1.45rem; min-height: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--vt-color-text-muted); opacity: 0; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); }
.wt-row:hover .wt-icon-btn { opacity: 1; } .wt-row:hover .wt-icon-btn { opacity: 1; }
.wt-icon-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); } .wt-icon-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-icon-btn.danger:hover:not(:disabled) { color: var(--vt-color-danger); background: var(--vt-color-danger-muted); border-color: rgba(233,69,96,0.35); } .wt-icon-btn.danger:hover:not(:disabled) { color: var(--vt-color-danger); background: var(--vt-color-danger-muted); border-color: rgba(233,69,96,0.35); }
.wt-rename { flex: 1; min-width: 0; background: #0f1424; border: 1px solid var(--vt-color-border-strong); color: var(--vt-color-text-primary); padding: 0.2rem 0.35rem; border-radius: var(--vt-radius-sm); font-size: 0.78rem; }
.wt-rename:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-create-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(4, 8, 18, 0.7); } .workspace-create-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(4, 8, 18, 0.7); }
.workspace-create-modal { width: min(34rem, 100%); display: grid; gap: 0.85rem; padding: 1rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-lg); background: var(--vt-color-surface); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38); } .workspace-create-modal { width: min(34rem, 100%); display: grid; gap: 0.85rem; padding: 1rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-lg); background: var(--vt-color-surface); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38); }
.workspace-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } .workspace-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }

View File

@ -626,6 +626,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
return { return {
configured: false, configured: false,
serverUrl: '', serverUrl: '',
vaultId: '',
deviceId: 'mock-device', deviceId: 'mock-device',
deviceName: '', deviceName: '',
connected: false, connected: false,
@ -635,6 +636,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
lastSyncAt: '', lastSyncAt: '',
syncInterval: 0, syncInterval: 0,
lastError: '', lastError: '',
lastWarning: '',
statusLabel: 'disabled', statusLabel: 'disabled',
serverSequence: 0 serverSequence: 0
}; };
@ -649,7 +651,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
if (p.charAt(0) === '/' || /^[A-Za-z]:/.test(p)) return { error: 'invalid-path: absolute path rejected' }; if (p.charAt(0) === '/' || /^[A-Za-z]:/.test(p)) return { error: 'invalid-path: absolute path rejected' };
var parts = p.split('/').filter(Boolean); var parts = p.split('/').filter(Boolean);
if (parts.indexOf('..') !== -1) return { error: 'invalid-path: path-traversal' }; if (parts.indexOf('..') !== -1) return { error: 'invalid-path: path-traversal' };
if (parts[0] && parts[0].toLowerCase() === '.verstak') return { error: 'reserved-path: .verstak is internal' }; if (parts.some(function(part) { return part.toLowerCase() === '.verstak'; })) return { error: 'reserved-path: .verstak is internal' };
return { path: parts.join('/') }; return { path: parts.join('/') };
} }
@ -751,6 +753,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
return { return {
configured: syncState.configured, configured: syncState.configured,
serverUrl: syncState.serverUrl, serverUrl: syncState.serverUrl,
vaultId: syncState.vaultId,
deviceId: syncState.deviceId, deviceId: syncState.deviceId,
deviceName: syncState.deviceName, deviceName: syncState.deviceName,
connected: syncState.connected, connected: syncState.connected,
@ -760,6 +763,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
lastSyncAt: syncState.lastSyncAt, lastSyncAt: syncState.lastSyncAt,
syncInterval: syncState.syncInterval, syncInterval: syncState.syncInterval,
lastError: syncState.lastError, lastError: syncState.lastError,
lastWarning: syncState.lastWarning,
statusLabel: syncState.statusLabel statusLabel: syncState.statusLabel
}; };
} }
@ -3477,11 +3481,12 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
if (err) return Promise.resolve([{}, err]); if (err) return Promise.resolve([{}, err]);
return Promise.resolve([syncStatusDTO(), '']); return Promise.resolve([syncStatusDTO(), '']);
}, },
PluginSyncConfigure: function (pluginId, serverUrl) { PluginSyncConfigure: function (pluginId, serverUrl, username, password, vaultId) {
var err = requirePluginSyncPermission(pluginId, true); var err = requirePluginSyncPermission(pluginId, true);
if (err) return Promise.resolve(err); if (err) return Promise.resolve(err);
syncState.configured = true; syncState.configured = true;
syncState.serverUrl = serverUrl || ''; syncState.serverUrl = serverUrl || '';
syncState.vaultId = vaultId || 'test-vault-001';
syncState.deviceId = 'mock-device'; syncState.deviceId = 'mock-device';
syncState.deviceName = 'mock-device'; syncState.deviceName = 'mock-device';
syncState.connected = true; syncState.connected = true;
@ -3491,6 +3496,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
syncState.statusLabel = 'connected'; syncState.statusLabel = 'connected';
pluginSettings[pluginId] = Object.assign({}, pluginSettings[pluginId] || {}, { pluginSettings[pluginId] = Object.assign({}, pluginSettings[pluginId] || {}, {
serverUrl: syncState.serverUrl, serverUrl: syncState.serverUrl,
vaultId: syncState.vaultId,
syncStatus: syncState.statusLabel syncStatus: syncState.statusLabel
}); });
return Promise.resolve(''); return Promise.resolve('');

View File

@ -114,7 +114,7 @@ export function PluginSecretsUnlock(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>; export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>; export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string,arg5:string):Promise<string>;
export function PluginSyncDisconnect(arg1:string):Promise<string>; export function PluginSyncDisconnect(arg1:string):Promise<string>;

View File

@ -210,8 +210,8 @@ export function PluginSecretsWrite(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2); return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2);
} }
export function PluginSyncConfigure(arg1, arg2, arg3, arg4) { export function PluginSyncConfigure(arg1, arg2, arg3, arg4, arg5) {
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4); return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4, arg5);
} }
export function PluginSyncDisconnect(arg1) { export function PluginSyncDisconnect(arg1) {

View File

@ -310,6 +310,7 @@ export namespace api {
export class SyncStatusDTO { export class SyncStatusDTO {
configured: boolean; configured: boolean;
serverUrl: string; serverUrl: string;
vaultId: string;
deviceId: string; deviceId: string;
deviceName: string; deviceName: string;
connected: boolean; connected: boolean;
@ -319,6 +320,7 @@ export namespace api {
lastSyncAt: string; lastSyncAt: string;
syncInterval: number; syncInterval: number;
lastError: string; lastError: string;
lastWarning: string;
statusLabel: string; statusLabel: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@ -329,6 +331,7 @@ export namespace api {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.configured = source["configured"]; this.configured = source["configured"];
this.serverUrl = source["serverUrl"]; this.serverUrl = source["serverUrl"];
this.vaultId = source["vaultId"];
this.deviceId = source["deviceId"]; this.deviceId = source["deviceId"];
this.deviceName = source["deviceName"]; this.deviceName = source["deviceName"];
this.connected = source["connected"]; this.connected = source["connected"];
@ -338,6 +341,7 @@ export namespace api {
this.lastSyncAt = source["lastSyncAt"]; this.lastSyncAt = source["lastSyncAt"];
this.syncInterval = source["syncInterval"]; this.syncInterval = source["syncInterval"];
this.lastError = source["lastError"]; this.lastError = source["lastError"];
this.lastWarning = source["lastWarning"];
this.statusLabel = source["statusLabel"]; this.statusLabel = source["statusLabel"];
} }
} }

10
go.mod
View File

@ -3,7 +3,7 @@ module github.com/verstak/verstak-desktop
go 1.24.4 go 1.24.4
require ( require (
github.com/getlantern/systray v1.2.2 fyne.io/systray v1.12.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0 golang.org/x/net v0.35.0
@ -12,14 +12,7 @@ require (
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
@ -31,7 +24,6 @@ require (
github.com/leaanthony/u v1.1.1 // indirect github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect

28
go.sum
View File

@ -1,28 +1,13 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -45,8 +30,6 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@ -55,8 +38,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -68,9 +49,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@ -91,7 +69,6 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -104,6 +81,5 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

File diff suppressed because it is too large Load Diff

View File

@ -175,6 +175,12 @@ func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, s
t.Helper() t.Helper()
app, root := newFilesTestApp(t, perms) app, root := newFilesTestApp(t, perms)
app.syncSvc = syncsvc.NewService(root, deviceID) app.syncSvc = syncsvc.NewService(root, deviceID)
if _, err := app.syncSvc.ScanAndRecord(); err != nil {
t.Fatalf("initial sync snapshot: %v", err)
}
if err := app.syncSvc.SetBootstrapComplete(true); err != nil {
t.Fatalf("mark test sync bootstrap complete: %v", err)
}
app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json")) app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := app.appSettings.Load(); err != nil { if err := app.appSettings.Load(); err != nil {
t.Fatalf("settings Load: %v", err) t.Fatalf("settings Load: %v", err)
@ -319,7 +325,7 @@ func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) {
} }
} }
func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) { func TestBeforeCloseAllowsExitUntilTrayIsReady(t *testing.T) {
oldHide := hideNativeWindow oldHide := hideNativeWindow
defer func() { hideNativeWindow = oldHide }() defer func() { hideNativeWindow = oldHide }()
@ -327,8 +333,25 @@ func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
hideNativeWindow = func(context.Context) { hideCalls++ } hideNativeWindow = func(context.Context) { hideCalls++ }
app := &App{} app := &App{}
if prevent := app.BeforeClose(context.Background()); prevent {
t.Fatal("BeforeClose() = true, want false while tray is unavailable")
}
if hideCalls != 0 {
t.Fatalf("hide calls = %d, want 0", hideCalls)
}
}
func TestBeforeCloseHidesWindowAfterTrayIsReady(t *testing.T) {
oldHide := hideNativeWindow
defer func() { hideNativeWindow = oldHide }()
hideCalls := 0
hideNativeWindow = func(context.Context) { hideCalls++ }
app := &App{}
app.SetTrayReady(true)
if prevent := app.BeforeClose(context.Background()); !prevent { if prevent := app.BeforeClose(context.Background()); !prevent {
t.Fatal("BeforeClose() = false, want true while tray mode is active") t.Fatal("BeforeClose() = false, want true while tray is ready")
} }
if hideCalls != 1 { if hideCalls != 1 {
t.Fatalf("hide calls = %d, want 1", hideCalls) t.Fatalf("hide calls = %d, want 1", hideCalls)
@ -353,6 +376,22 @@ func TestTrayQuitAllowsWindowCloseAndQuitsApplication(t *testing.T) {
} }
} }
func TestTrayQuitRequestsNativeShutdownOnlyOnce(t *testing.T) {
oldQuit := quitNativeApplication
defer func() { quitNativeApplication = oldQuit }()
quitCalls := 0
quitNativeApplication = func(context.Context) { quitCalls++ }
app := &App{ctx: context.Background()}
app.Quit()
app.Quit()
if quitCalls != 1 {
t.Fatalf("native quit calls = %d, want 1", quitCalls)
}
}
func TestShowWindowUsesWailsContext(t *testing.T) { func TestShowWindowUsesWailsContext(t *testing.T) {
oldShow := showNativeWindow oldShow := showNativeWindow
defer func() { showNativeWindow = oldShow }() defer func() { showNativeWindow = oldShow }()
@ -1659,6 +1698,63 @@ func TestApplyRemoteOpSkipsLocalDevice(t *testing.T) {
} }
} }
func TestApplyRemoteWorkspaceLifecyclePreservesDurableIdentity(t *testing.T) {
app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
app.workspace = workspace.NewManager(root)
if err := app.workspace.Load(); err != nil {
t.Fatal(err)
}
workspaceID := "5f0f96d9-61c8-4b6b-8c3a-a1b9a0f40002"
payload := syncWorkspacePayload{
WorkspaceID: workspaceID,
Path: "Remote",
Name: "Remote",
Metadata: workspace.Metadata{
WorkspaceID: workspaceID,
WorkspaceName: "Remote",
Folders: map[string]string{"notes": "Notes"},
Features: map[string]bool{"files": true},
},
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
create := syncsvc.Op{OpID: "workspace-create", DeviceID: "remote-device", EntityType: syncsvc.EntityWorkspace, EntityID: workspaceID, OpType: syncsvc.OpCreate, PayloadJSON: string(encoded)}
if err := app.applyRemoteOp(create); err != nil {
t.Fatalf("apply workspace create: %v", err)
}
if err := app.applyRemoteOp(create); err != nil {
t.Fatalf("replay workspace create: %v", err)
}
if identity, err := app.workspace.GetWorkspaceIdentity("Remote"); err != nil || identity.WorkspaceID != workspaceID {
t.Fatalf("remote workspace identity = %+v err=%v", identity, err)
}
payload.PreviousPath = "Remote"
payload.Path = "Remote-Renamed"
payload.Name = "Remote-Renamed"
encoded, _ = json.Marshal(payload)
rename := syncsvc.Op{OpID: "workspace-rename", DeviceID: "remote-device", EntityType: syncsvc.EntityWorkspace, EntityID: workspaceID, OpType: syncsvc.OpRename, PayloadJSON: string(encoded)}
if err := app.applyRemoteOp(rename); err != nil {
t.Fatalf("apply workspace rename: %v", err)
}
trash := syncsvc.Op{OpID: "workspace-trash", DeviceID: "remote-device", EntityType: syncsvc.EntityWorkspace, EntityID: workspaceID, OpType: syncsvc.OpTrash, PayloadJSON: string(encoded)}
if err := app.applyRemoteOp(trash); err != nil {
t.Fatalf("apply workspace trash: %v", err)
}
payload.Path = "Remote-Restored"
payload.Name = "Remote-Restored"
encoded, _ = json.Marshal(payload)
restore := syncsvc.Op{OpID: "workspace-restore", DeviceID: "remote-device", EntityType: syncsvc.EntityWorkspace, EntityID: workspaceID, OpType: syncsvc.OpRestore, PayloadJSON: string(encoded)}
if err := app.applyRemoteOp(restore); err != nil {
t.Fatalf("apply workspace restore: %v", err)
}
if identity, err := app.workspace.GetWorkspaceIdentity("Remote-Restored"); err != nil || identity.WorkspaceID != workspaceID {
t.Fatalf("restored workspace identity = %+v err=%v", identity, err)
}
}
func TestFileBridgeRecordsSyncOps(t *testing.T) { func TestFileBridgeRecordsSyncOps(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device") app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
@ -1685,8 +1781,8 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetUnpushedOps: %v", err) t.Fatalf("GetUnpushedOps: %v", err)
} }
if len(ops) != 6 { if len(ops) != 7 {
t.Fatalf("ops len = %d, want 6: %#v", len(ops), ops) t.Fatalf("ops len = %d, want 7: %#v", len(ops), ops)
} }
want := []struct { want := []struct {
@ -1698,8 +1794,11 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
{syncsvc.EntityFolder, "Docs", syncsvc.OpCreate, `"path":"Docs"`}, {syncsvc.EntityFolder, "Docs", syncsvc.OpCreate, `"path":"Docs"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, `"content":"hello"`}, {syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, `"content":"hello"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpUpdate, `"content":"updated"`}, {syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpUpdate, `"content":"updated"`},
{syncsvc.EntityFile, "Docs/image.bin", syncsvc.OpCreate, `"dataBase64":"AQID"`}, {syncsvc.EntityFile, "Docs/image.bin", syncsvc.OpCreate, `"blob":{"sha256":`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpMove, `"toPath":"Docs/two.txt"`}, // Snapshot reconciliation represents external and API renames uniformly
// as a create at the destination followed by a delete at the source.
{syncsvc.EntityFile, "Docs/two.txt", syncsvc.OpCreate, `"content":"updated"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpDelete, `"path":"Docs/one.txt"`},
{syncsvc.EntityFile, "Docs/two.txt", syncsvc.OpDelete, `"path":"Docs/two.txt"`}, {syncsvc.EntityFile, "Docs/two.txt", syncsvc.OpDelete, `"path":"Docs/two.txt"`},
} }
for i, w := range want { for i, w := range want {
@ -1821,8 +1920,8 @@ func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) {
t.Fatalf("pushed ops len = %d, want 2", len(pushedOps)) t.Fatalf("pushed ops len = %d, want 2", len(pushedOps))
} }
for i, op := range pushedOps { for i, op := range pushedOps {
if op.LastSeenServerSeq != 0 { if op.LastSeenServerSeq != 2 {
t.Fatalf("pushed op[%d] last seen = %d, want 0", i, op.LastSeenServerSeq) t.Fatalf("pushed op[%d] last seen = %d, want 2 after initial pull", i, op.LastSeenServerSeq)
} }
} }
@ -1849,6 +1948,369 @@ func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) {
} }
} }
func TestSyncNowAppliesEveryPullPageInOrder(t *testing.T) {
app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer device-token" {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/sync/pull":
var request syncsvc.PullRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
pages := map[int]map[string]interface{}{
0: {"server_sequence": 3, "page_last_sequence": 1, "has_more": true, "ops": []map[string]interface{}{{"op_id": "folder", "server_sequence": 1, "device_id": "remote", "entity_type": syncsvc.EntityFolder, "entity_id": "Remote", "op_type": syncsvc.OpCreate, "payload_json": `{"path":"Remote"}`}}},
1: {"server_sequence": 3, "page_last_sequence": 2, "has_more": true, "ops": []map[string]interface{}{{"op_id": "one", "server_sequence": 2, "device_id": "remote", "entity_type": syncsvc.EntityFile, "entity_id": "Remote/one.txt", "op_type": syncsvc.OpCreate, "payload_json": `{"path":"Remote/one.txt","content":"one"}`}}},
2: {"server_sequence": 3, "page_last_sequence": 3, "has_more": false, "ops": []map[string]interface{}{{"op_id": "two", "server_sequence": 3, "device_id": "remote", "entity_type": syncsvc.EntityFile, "entity_id": "Remote/two.txt", "op_type": syncsvc.OpCreate, "payload_json": `{"path":"Remote/two.txt","content":"two"}`}}},
3: {"server_sequence": 3, "page_last_sequence": 3, "has_more": false, "ops": []map[string]interface{}{}},
}
_ = json.NewEncoder(w).Encode(pages[request.SinceSequence])
case "/api/v1/sync/push":
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accepted": []string{}, "count": 0, "conflicts": []map[string]interface{}{}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
if err := app.syncSvc.SetState(server.URL, ""); err != nil {
t.Fatal(err)
}
if err := syncsvc.SaveDeviceToken(root, "device-token"); err != nil {
t.Fatal(err)
}
result, err := app.syncNow()
if err != nil {
t.Fatal(err)
}
if result["pulled"] != 3 {
t.Fatalf("pull result = %#v, want three operations", result)
}
expectText(t, app, "Remote/one.txt", "one")
expectText(t, app, "Remote/two.txt", "two")
assertLastPullSequence(t, app.syncSvc, 3)
}
func TestSyncNowStopsAtFailedRemoteOperationAndRetriesAfterRestart(t *testing.T) {
app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
pullCalls := 0
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer device-token" {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/sync/pull":
pullCalls++
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"server_sequence": 3,
"ops": []map[string]interface{}{
{
"op_id": "remote-folder",
"server_sequence": 1,
"device_id": "remote-device",
"entity_type": syncsvc.EntityFolder,
"entity_id": "Remote",
"op_type": syncsvc.OpCreate,
"payload_json": `{"path":"Remote"}`,
},
{
"op_id": "blocked-file",
"server_sequence": 2,
"device_id": "remote-device",
"entity_type": syncsvc.EntityFile,
"entity_id": "Missing/blocked.txt",
"op_type": syncsvc.OpCreate,
"payload_json": `{"path":"Missing/blocked.txt","content":"blocked"}`,
},
{
"op_id": "after-failure",
"server_sequence": 3,
"device_id": "remote-device",
"entity_type": syncsvc.EntityFile,
"entity_id": "Remote/after.txt",
"op_type": syncsvc.OpCreate,
"payload_json": `{"path":"Remote/after.txt","content":"must wait"}`,
},
},
})
case "/api/v1/sync/push":
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accepted": []string{}, "count": 0, "conflicts": []map[string]interface{}{}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
if err := app.syncSvc.SetState(server.URL, ""); err != nil {
t.Fatal(err)
}
if err := syncsvc.SaveDeviceToken(root, "device-token"); err != nil {
t.Fatal(err)
}
if _, err := app.syncNow(); err == nil || !strings.Contains(err.Error(), "sequence 2") {
t.Fatalf("first sync error = %v, want failed sequence", err)
}
assertLastPullSequence(t, app.syncSvc, 1)
if _, errStr := app.GetVaultFileMetadata("files.plugin", "Remote/after.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("operation after failure applied early: %q", errStr)
}
cfg := app.appSettings.Get()
if cfg.Sync.LastError == "" || !strings.Contains(cfg.Sync.LastError, "sequence 2") || !strings.Contains(cfg.Sync.LastError, "Missing/blocked.txt") {
t.Fatalf("sync status after failed apply = %#v", cfg.Sync)
}
if err := os.Mkdir(filepath.Join(root, "Missing"), 0o755); err != nil {
t.Fatal(err)
}
restarted := &App{
vault: app.vault,
files: app.files,
plugins: app.plugins,
appSettings: app.appSettings,
syncSvc: syncsvc.NewService(root, ""),
}
if _, err := restarted.syncNow(); err != nil {
t.Fatalf("retry after restart: %v", err)
}
if pullCalls < 2 {
t.Fatalf("pull calls = %d, want retry", pullCalls)
}
assertLastPullSequence(t, restarted.syncSvc, 3)
expectText(t, restarted, "Missing/blocked.txt", "blocked")
expectText(t, restarted, "Remote/after.txt", "must wait")
}
func TestSyncNowBootstrapsExistingLocalSnapshotAfterInitialPull(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
if err := os.Mkdir(filepath.Join(root, "Existing"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Existing", "before-connect.txt"), []byte("local before connect"), 0o644); err != nil {
t.Fatal(err)
}
app.syncSvc = syncsvc.NewService(root, "local-device")
if _, err := app.syncSvc.ScanAndRecord(); err != nil {
t.Fatalf("initial baseline scan: %v", err)
}
app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := app.appSettings.Load(); err != nil {
t.Fatal(err)
}
var pushed []syncsvc.PushOp
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer device-token" {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/sync/pull":
_ = json.NewEncoder(w).Encode(map[string]interface{}{"server_sequence": 0, "ops": []map[string]interface{}{}})
case "/api/v1/sync/push":
var request struct {
Ops []syncsvc.PushOp `json:"ops"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pushed = request.Ops
accepted := make([]string, 0, len(request.Ops))
for _, op := range request.Ops {
accepted = append(accepted, op.OpID)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accepted": accepted, "count": len(accepted), "conflicts": []map[string]interface{}{}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
if err := app.syncSvc.SetState(server.URL, ""); err != nil {
t.Fatal(err)
}
if err := syncsvc.SaveDeviceToken(root, "device-token"); err != nil {
t.Fatal(err)
}
result, err := app.syncNow()
if err != nil {
t.Fatalf("syncNow: %v", err)
}
if result["pushed"] != len(pushed) || len(pushed) < 2 {
t.Fatalf("result=%#v pushed=%#v, want initial entries", result, pushed)
}
foundFolder, foundFile := false, false
for _, op := range pushed {
if op.EntityID == "Existing" && op.EntityType == syncsvc.EntityFolder && op.OpType == syncsvc.OpCreate {
foundFolder = true
}
if op.EntityID == "Existing/before-connect.txt" && op.EntityType == syncsvc.EntityFile && op.OpType == syncsvc.OpCreate {
foundFile = true
}
if strings.Contains(op.EntityID, "/.verstak/") {
t.Fatalf("ordinary bootstrap operation leaked internal path: %+v", op)
}
}
if !foundFolder || !foundFile {
t.Fatalf("bootstrap push = %#v, missing existing file or folder", pushed)
}
bootstrapped, err := app.syncSvc.BootstrapComplete()
if err != nil || !bootstrapped {
t.Fatalf("bootstrap complete = %v err=%v", bootstrapped, err)
}
}
func TestInitialSyncConflictDoesNotOverwriteLocalFileOrAdvanceCursor(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
if err := os.WriteFile(filepath.Join(root, "same-name.txt"), []byte("local value"), 0o644); err != nil {
t.Fatal(err)
}
app.syncSvc = syncsvc.NewService(root, "local-device")
if _, err := app.syncSvc.ScanAndRecord(); err != nil {
t.Fatalf("initial baseline scan: %v", err)
}
app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := app.appSettings.Load(); err != nil {
t.Fatal(err)
}
pushCalls := 0
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer device-token" {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/sync/pull":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"server_sequence": 1,
"ops": []map[string]interface{}{{
"op_id": "remote-update",
"server_sequence": 1,
"device_id": "remote-device",
"entity_type": syncsvc.EntityFile,
"entity_id": "same-name.txt",
"op_type": syncsvc.OpUpdate,
"payload_json": `{"path":"same-name.txt","content":"remote value"}`,
}},
})
case "/api/v1/sync/push":
pushCalls++
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accepted": []string{}, "count": 0, "conflicts": []map[string]interface{}{}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
if err := app.syncSvc.SetState(server.URL, ""); err != nil {
t.Fatal(err)
}
if err := syncsvc.SaveDeviceToken(root, "device-token"); err != nil {
t.Fatal(err)
}
if _, err := app.syncNow(); err == nil || !strings.Contains(err.Error(), "initial reconciliation") || !strings.Contains(err.Error(), "same-name.txt") {
t.Fatalf("sync error = %v, want explicit initial conflict", err)
}
expectText(t, app, "same-name.txt", "local value")
assertLastPullSequence(t, app.syncSvc, 0)
if pushCalls != 0 {
t.Fatalf("push calls = %d, conflict must not overwrite remote state", pushCalls)
}
bootstrapped, err := app.syncSvc.BootstrapComplete()
if err != nil || bootstrapped {
t.Fatalf("bootstrap state = %v err=%v, conflict must remain unresolved", bootstrapped, err)
}
}
func TestInitialSyncEmptyVaultRestoresRemoteDataWithoutDeletePush(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
app.syncSvc = syncsvc.NewService(root, "empty-device")
if _, err := app.syncSvc.ScanAndRecord(); err != nil {
t.Fatalf("initial empty baseline: %v", err)
}
app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := app.appSettings.Load(); err != nil {
t.Fatal(err)
}
var pushed []syncsvc.PushOp
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer device-token" {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/sync/pull":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"server_sequence": 1,
"ops": []map[string]interface{}{{
"op_id": "remote-file",
"server_sequence": 1,
"device_id": "remote-device",
"entity_type": syncsvc.EntityFile,
"entity_id": "restored.txt",
"op_type": syncsvc.OpCreate,
"payload_json": `{"path":"restored.txt","content":"from remote"}`,
}},
})
case "/api/v1/sync/push":
var request struct {
Ops []syncsvc.PushOp `json:"ops"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pushed = append(pushed, request.Ops...)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accepted": []string{}, "count": 0, "conflicts": []map[string]interface{}{}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
if err := app.syncSvc.SetState(server.URL, ""); err != nil {
t.Fatal(err)
}
if err := syncsvc.SaveDeviceToken(root, "device-token"); err != nil {
t.Fatal(err)
}
result, err := app.syncNow()
if err != nil {
t.Fatalf("sync empty vault: %v", err)
}
expectText(t, app, "restored.txt", "from remote")
for _, op := range pushed {
if op.OpType == syncsvc.OpDelete {
t.Fatalf("empty vault published delete operation: %+v", op)
}
}
if result["pushed"] != 0 && len(pushed) == 0 {
t.Fatalf("result reports pushed operations without a push payload: %#v", result)
}
assertLastPullSequence(t, app.syncSvc, 1)
}
func assertLastPullSequence(t *testing.T, service *syncsvc.Service, want int) {
t.Helper()
_, _, got, _, err := service.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if got != want {
t.Fatalf("last pull sequence = %d, want %d", got, want)
}
}
func TestSyncConfigurePairsCurrentVaultID(t *testing.T) { func TestSyncConfigurePairsCurrentVaultID(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device") app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
meta := app.vault.GetVaultMeta() meta := app.vault.GetVaultMeta()
@ -1878,7 +2340,7 @@ func TestSyncConfigurePairsCurrentVaultID(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
if err := app.syncConfigure(server.URL, "alice", "secret"); err != nil { if err := app.syncConfigure(server.URL, "alice", "secret", ""); err != nil {
t.Fatalf("syncConfigure: %v", err) t.Fatalf("syncConfigure: %v", err)
} }
if pairedVaultID != meta.VaultID { if pairedVaultID != meta.VaultID {
@ -1886,6 +2348,79 @@ func TestSyncConfigurePairsCurrentVaultID(t *testing.T) {
} }
} }
func TestSyncConfigurePairsSpecifiedRemoteVaultID(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
const remoteVaultID = "existing-remote-vault"
var pairedVaultID string
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/client/pair" {
http.NotFound(w, r)
return
}
var request struct {
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pairedVaultID = request.VaultID
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"device_id": "paired-device",
"device_token": "paired-token",
})
}))
defer server.Close()
if err := app.syncConfigure(server.URL, "alice", "secret", remoteVaultID); err != nil {
t.Fatalf("syncConfigure: %v", err)
}
if pairedVaultID != remoteVaultID {
t.Fatalf("paired vault ID = %q, want %q", pairedVaultID, remoteVaultID)
}
storedRemoteVaultID, err := app.syncSvc.RemoteVaultID()
if err != nil || storedRemoteVaultID != remoteVaultID {
t.Fatalf("stored remote vault ID = %q err=%v, want %q", storedRemoteVaultID, err, remoteVaultID)
}
bootstrapped, err := app.syncSvc.BootstrapComplete()
if err != nil || bootstrapped {
t.Fatalf("bootstrap state after new pairing = %v err=%v, want false", bootstrapped, err)
}
}
func TestSyncConfigureRefusesRemoteScopeChangeWithUnpushedOperations(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
if err := app.syncSvc.SetState("https://old-sync.example.test", ""); err != nil {
t.Fatal(err)
}
if err := app.syncSvc.SetRemoteVaultID("old-remote-vault"); err != nil {
t.Fatal(err)
}
if err := app.syncSvc.RecordOp(syncsvc.EntityFile, "pending.txt", syncsvc.OpCreate, map[string]string{"path": "pending.txt", "content": "local"}); err != nil {
t.Fatal(err)
}
err := app.syncConfigure("https://new-sync.example.test", "alice", "secret", "new-remote-vault")
if err == nil || !strings.Contains(err.Error(), "unpushed local operation") {
t.Fatalf("scope-change error = %v, want pending-operation refusal", err)
}
}
func TestSyncStatusExposesPersistentScannerWarning(t *testing.T) {
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
if err := app.syncSvc.SetLastWarning("file-too-large: archive.bin"); err != nil {
t.Fatal(err)
}
status, err := app.syncStatus()
if err != nil {
t.Fatalf("syncStatus: %v", err)
}
if status.LastWarning != "file-too-large: archive.bin" {
t.Fatalf("last warning = %q", status.LastWarning)
}
}
func TestSyncNowHydratesLegacyVaultDeviceID(t *testing.T) { func TestSyncNowHydratesLegacyVaultDeviceID(t *testing.T) {
app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "wrong-global-device") app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "wrong-global-device")
var pushedDeviceID string var pushedDeviceID string
@ -2089,9 +2624,6 @@ func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
if len(nodes) == 0 { if len(nodes) == 0 {
t.Fatal("workspace nodes should not be empty") t.Fatal("workspace nodes should not be empty")
} }
if nodes[0].Path != "" {
t.Fatalf("compatibility node should not expose workspace path mapping: %+v", nodes[0])
}
if !app.capRegistry.Has("verstak/core/workspace/v1") { if !app.capRegistry.Has("verstak/core/workspace/v1") {
t.Fatal("workspace capability should be registered after SetCurrentVault") t.Fatal("workspace capability should be registered after SetCurrentVault")
} }
@ -2370,11 +2902,11 @@ func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
} }
errStr := app.MoveWorkspaceNode("Project", "Test") errStr := app.MoveWorkspaceNode("Project", "Test")
if errStr == "" || !strings.Contains(errStr, "top-level only") { if errStr == "" || !strings.Contains(errStr, "parent-is-workspace") {
t.Fatalf("MoveWorkspaceNode error = %q, want top-level only", errStr) t.Fatalf("MoveWorkspaceNode error = %q, want parent-is-workspace", errStr)
} }
if _, err := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(err) {
t.Fatalf("MoveWorkspaceNode created nested mapped workspace, stat err=%v", err) t.Fatalf("MoveWorkspaceNode created nested workspace inside another workspace, stat err=%v", err)
} }
} }

View File

@ -1,11 +1,14 @@
package api package api
import ( import (
"bytes"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
corefiles "github.com/verstak/verstak-desktop/internal/core/files" corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/workspace"
) )
func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) { func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
@ -18,8 +21,16 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
t.Skip("set VERSTAK_SYNC_SMOKE_* env vars to run the real sync-server smoke test") t.Skip("set VERSTAK_SYNC_SMOKE_* env vars to run the real sync-server smoke test")
} }
appA, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceA) appA, rootA := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceA)
appB, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceB) appB, rootB := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceB)
appA.workspace = workspace.NewManager(rootA)
appB.workspace = workspace.NewManager(rootB)
if err := appA.workspace.Load(); err != nil {
t.Fatalf("load workspace A: %v", err)
}
if err := appB.workspace.Load(); err != nil {
t.Fatalf("load workspace B: %v", err)
}
if err := appA.syncSvc.SetState(serverURL, apiKeyA); err != nil { if err := appA.syncSvc.SetState(serverURL, apiKeyA); err != nil {
t.Fatalf("appA SetState: %v", err) t.Fatalf("appA SetState: %v", err)
} }
@ -43,8 +54,8 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
if errStr := appB.MoveVaultPath("files.plugin", "Shared/one.txt", "Shared/two.txt", corefiles.MoveOptions{}); errStr != "" { if errStr := appB.MoveVaultPath("files.plugin", "Shared/one.txt", "Shared/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appB move: %s", errStr) t.Fatalf("appB move: %s", errStr)
} }
expectSyncCounts(t, appB, 2, 2) expectSyncCounts(t, appB, 3, 3)
expectSyncCounts(t, appA, 0, 2) expectSyncCounts(t, appA, 0, 3)
expectText(t, appA, "Shared/two.txt", "from B") expectText(t, appA, "Shared/two.txt", "from B")
if _, errStr := appA.TrashVaultPath("files.plugin", "Shared/two.txt"); errStr != "" { if _, errStr := appA.TrashVaultPath("files.plugin", "Shared/two.txt"); errStr != "" {
@ -68,8 +79,8 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
if errStr := appA.MoveVaultPath("files.plugin", "Shared/Folder", "Shared/Archive", corefiles.MoveOptions{}); errStr != "" { if errStr := appA.MoveVaultPath("files.plugin", "Shared/Folder", "Shared/Archive", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appA move folder: %s", errStr) t.Fatalf("appA move folder: %s", errStr)
} }
expectSyncCounts(t, appA, 1, 1) expectSyncCounts(t, appA, 2, 2)
expectSyncCounts(t, appB, 0, 1) expectSyncCounts(t, appB, 0, 2)
if _, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/Folder"); !strings.Contains(errStr, "not-found") { if _, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/Folder"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appB moved folder old metadata err = %q, want not-found", errStr) t.Fatalf("appB moved folder old metadata err = %q, want not-found", errStr)
} }
@ -85,6 +96,83 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
if _, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/Archive"); !strings.Contains(errStr, "not-found") { if _, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/Archive"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appA deleted folder metadata err = %q, want not-found", errStr) t.Fatalf("appA deleted folder metadata err = %q, want not-found", errStr)
} }
if err := os.WriteFile(filepath.Join(rootA, "Shared", "external.txt"), []byte("external while running"), 0o644); err != nil {
t.Fatalf("external create: %v", err)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
expectText(t, appB, "Shared/external.txt", "external while running")
assertNoUnpushedOps(t, appB)
// This exceeds the former 8 MiB inline/base64 ceiling. The operation must
// contain only a blob reference; the actual bytes travel through Blob API.
binary := make([]byte, corefiles.MaxBinaryReadBytes+1)
for i := range binary {
binary[i] = byte(i % 251)
}
if err := os.WriteFile(filepath.Join(rootA, "Shared", "large.bin"), binary, 0o644); err != nil {
t.Fatalf("write large binary: %v", err)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
received, err := os.ReadFile(filepath.Join(rootB, "Shared", "large.bin"))
if err != nil {
t.Fatalf("read synced large binary: %v", err)
}
if !bytes.Equal(received, binary) {
t.Fatal("large binary content differs after blob sync")
}
assertNoUnpushedOps(t, appB)
if err := os.WriteFile(filepath.Join(rootB, "Shared", "external.txt"), []byte("external while closed"), 0o644); err != nil {
t.Fatalf("offline external update: %v", err)
}
if err := os.WriteFile(filepath.Join(rootB, "Shared", "offline-created.txt"), []byte("created while closed"), 0o644); err != nil {
t.Fatalf("offline external create: %v", err)
}
if err := os.Remove(filepath.Join(rootB, "Shared", "external.txt")); err != nil {
t.Fatalf("offline external delete: %v", err)
}
expectSyncCounts(t, appB, 2, 2)
expectSyncCounts(t, appA, 0, 2)
if _, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/external.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("offline deleted file remained on appA: %q", errStr)
}
expectText(t, appA, "Shared/offline-created.txt", "created while closed")
assertNoUnpushedOps(t, appA)
deal, errStr := appA.CreateWorkspace("Synced Deal", "minimal")
if errStr != "" {
t.Fatalf("appA CreateWorkspace: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
assertWorkspaceIdentity(t, appB, "Synced Deal", deal.ID)
if errStr := appA.RenameWorkspace("Synced Deal", "Renamed Deal"); errStr != "" {
t.Fatalf("appA RenameWorkspace: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
assertWorkspaceIdentity(t, appB, "Renamed Deal", deal.ID)
trash, errStr := appA.TrashWorkspace("Renamed Deal")
if errStr != "" {
t.Fatalf("appA TrashWorkspace: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
if _, err := appB.workspace.GetWorkspaceIdentity("Renamed Deal"); err == nil {
t.Fatal("trashed workspace is still active on appB")
}
if _, errStr := appA.RestoreWorkspaceTrash(trash.TrashID, "Restored Deal"); errStr != "" {
t.Fatalf("appA RestoreWorkspaceTrash: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
assertWorkspaceIdentity(t, appB, "Restored Deal", deal.ID)
} }
func expectSyncCounts(t *testing.T, app *App, pushed, pulled int) { func expectSyncCounts(t *testing.T, app *App, pushed, pulled int) {
@ -108,3 +196,25 @@ func expectText(t *testing.T, app *App, path, want string) {
t.Fatalf("ReadVaultTextFile(%s) = %q, want %q", path, text, want) t.Fatalf("ReadVaultTextFile(%s) = %q, want %q", path, text, want)
} }
} }
func assertNoUnpushedOps(t *testing.T, app *App) {
t.Helper()
ops, err := app.syncSvc.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
if len(ops) != 0 {
t.Fatalf("remote operations were echoed as local operations: %#v", ops)
}
}
func assertWorkspaceIdentity(t *testing.T, app *App, name, wantID string) {
t.Helper()
identity, err := app.workspace.GetWorkspaceIdentity(name)
if err != nil {
t.Fatalf("GetWorkspaceIdentity(%s): %v", name, err)
}
if identity.WorkspaceID != wantID {
t.Fatalf("workspace %s ID = %s, want %s", name, identity.WorkspaceID, wantID)
}
}

View File

@ -11,6 +11,7 @@ var platformCapabilities = []string{
"verstak/core/files/v1", "verstak/core/files/v1",
"verstak/core/workbench/v1", "verstak/core/workbench/v1",
"verstak/core/notifications/v1", "verstak/core/notifications/v1",
"verstak/core/workspace/v1",
} }
// CorePlatformCapabilities returns a copy of the capabilities registered by // CorePlatformCapabilities returns a copy of the capabilities registered by

View File

@ -24,6 +24,13 @@ type Registry struct {
statusBarItems []ContributionStatusBarItem statusBarItems []ContributionStatusBarItem
openProviders []ContributionOpenProvider openProviders []ContributionOpenProvider
workspaceItems []ContributionWorkspaceItem workspaceItems []ContributionWorkspaceItem
workspaceTree *ContributionWorkspaceTree
}
// ContributionWorkspaceTree is a singleton contribution for replacing the Deal tree.
type ContributionWorkspaceTree struct {
PluginID string `json:"pluginId"`
Component string `json:"component"`
} }
// ContributionPointType defines the type of contribution point. // ContributionPointType defines the type of contribution point.
@ -42,6 +49,7 @@ const (
PointStatusBar ContributionPointType = "statusBarItems" PointStatusBar ContributionPointType = "statusBarItems"
PointOpenProviders ContributionPointType = "openProviders" PointOpenProviders ContributionPointType = "openProviders"
PointWorkspaceItems ContributionPointType = "workspaceItems" PointWorkspaceItems ContributionPointType = "workspaceItems"
PointWorkspaceTree ContributionPointType = "workspaceTree"
) )
// ListByPoint returns all contributions for a given point type. // ListByPoint returns all contributions for a given point type.
@ -184,6 +192,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID) r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID) r.openProviders = removeOpenProviders(r.openProviders, pluginID)
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID) r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
r.workspaceTree = nil
for _, item := range c.Views { for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item}) r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
@ -221,6 +230,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
for _, item := range c.WorkspaceItems { for _, item := range c.WorkspaceItems {
r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item}) r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item})
} }
if c.WorkspaceTree != nil && r.workspaceTree == nil {
r.workspaceTree = &ContributionWorkspaceTree{PluginID: pluginID, Component: c.WorkspaceTree.Component}
}
} }
// Unregister removes all contributions from a plugin. // Unregister removes all contributions from a plugin.
@ -240,6 +252,9 @@ func (r *Registry) Unregister(pluginID string) {
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID) r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID) r.openProviders = removeOpenProviders(r.openProviders, pluginID)
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID) r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
if r.workspaceTree != nil && r.workspaceTree.PluginID == pluginID {
r.workspaceTree = nil
}
} }
// Getters — sorted for deterministic display. // Getters — sorted for deterministic display.
@ -367,6 +382,13 @@ func (r *Registry) WorkspaceItems() []ContributionWorkspaceItem {
return result return result
} }
// WorkspaceTree returns the singleton workspaceTree contribution, or nil.
func (r *Registry) WorkspaceTree() *ContributionWorkspaceTree {
r.mu.RLock()
defer r.mu.RUnlock()
return r.workspaceTree
}
// ─── Remove helpers ───────────────────────────────────────── // ─── Remove helpers ─────────────────────────────────────────
func removeViews(items []ContributionView, pluginID string) []ContributionView { func removeViews(items []ContributionView, pluginID string) []ContributionView {

View File

@ -25,8 +25,7 @@ func IsReservedPath(relativePath string) bool {
if cleaned == "" { if cleaned == "" {
return false return false
} }
first := strings.Split(cleaned, "/")[0] return containsReservedSegment(cleaned)
return strings.EqualFold(first, ".verstak")
} }
func normalizeRelativePath(input string, allowRoot bool) (string, error) { func normalizeRelativePath(input string, allowRoot bool) (string, error) {
@ -67,8 +66,16 @@ func IsReservedPathNoNormalize(cleaned string) bool {
if cleaned == "" { if cleaned == "" {
return false return false
} }
first := strings.Split(cleaned, "/")[0] return containsReservedSegment(cleaned)
return strings.EqualFold(first, ".verstak") }
func containsReservedSegment(cleaned string) bool {
for _, segment := range strings.Split(cleaned, "/") {
if strings.EqualFold(segment, ".verstak") {
return true
}
}
return false
} }
func looksAbsolute(input string) bool { func looksAbsolute(input string) bool {

View File

@ -43,6 +43,7 @@ func TestNormalizeRelativeFileRejectsUnsafePaths(t *testing.T) {
".verstak/vault.json", ".verstak/vault.json",
"./.verstak", "./.verstak",
".verstak/trash", ".verstak/trash",
"Workspace/.verstak/workspace.json",
"folder/../.verstak", "folder/../.verstak",
".Verstak", ".Verstak",
} }
@ -70,6 +71,9 @@ func TestReservedPathPolicy(t *testing.T) {
if IsReservedPath("Notes/.verstak.md") { if IsReservedPath("Notes/.verstak.md") {
t.Fatal("Notes/.verstak.md should not be reserved") t.Fatal("Notes/.verstak.md should not be reserved")
} }
if !IsReservedPath("Workspace/.verstak/workspace.json") {
t.Fatal("nested .verstak should be reserved")
}
} }
func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) { func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) {

View File

@ -4,6 +4,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"mime" "mime"
"os" "os"
@ -201,6 +202,85 @@ func (s *Service) WriteVaultFileBytes(relativePath string, dataBase64 string, op
return s.writeVaultFileData(relativePath, data, options) return s.writeVaultFileData(relativePath, data, options)
} }
// WriteVaultFileFromPath streams a verified temporary file into the vault and
// replaces the destination atomically. It is used by core sync so a Blob never
// needs to be base64-decoded or held in memory.
func (s *Service) WriteVaultFileFromPath(relativePath, sourcePath string, options WriteOptions) error {
source, err := os.Open(sourcePath)
if err != nil {
return err
}
defer source.Close()
info, err := source.Stat()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("blob source is not a regular file")
}
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return err
}
parent := filepath.Dir(full)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
}
existing, err := os.Lstat(full)
if err == nil {
if existing.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !existing.Mode().IsRegular() {
return fmt.Errorf("not-regular-file: %s", rel)
}
if !options.Overwrite {
return fmt.Errorf("conflict: %s", rel)
}
} else if os.IsNotExist(err) {
if !options.CreateIfMissing {
return fmt.Errorf("not-found: %s", rel)
}
} else {
return err
}
tmp, err := os.CreateTemp(parent, ".verstak-write-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if _, err := io.Copy(tmp, source); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, full); err != nil {
return err
}
cleanup = false
return nil
}
func (s *Service) writeVaultFileData(relativePath string, data []byte, options WriteOptions) error { func (s *Service) writeVaultFileData(relativePath string, data []byte, options WriteOptions) error {
root, rel, full, err := s.resolveFile(relativePath) root, rel, full, err := s.resolveFile(relativePath)
if err != nil { if err != nil {

View File

@ -40,6 +40,18 @@ type Service struct {
cancel chan struct{} cancel chan struct{}
done chan struct{} done chan struct{}
current map[string]snapshotEntry current map[string]snapshotEntry
onChange func()
}
// SetOnChange installs a lightweight notification used by core services that
// need a debounced reconciliation after the watcher has observed a change.
func (s *Service) SetOnChange(callback func()) {
if s == nil {
return
}
s.mu.Lock()
s.onChange = callback
s.mu.Unlock()
} }
// NewService creates a watcher. The interval parameter is mainly for tests. // NewService creates a watcher. The interval parameter is mainly for tests.
@ -133,22 +145,30 @@ func (s *Service) poll(root string) {
s.mu.Lock() s.mu.Lock()
prev := s.current prev := s.current
s.current = next s.current = next
callback := s.onChange
s.mu.Unlock() s.mu.Unlock()
changed := false
for path, entry := range next { for path, entry := range next {
old, ok := prev[path] old, ok := prev[path]
if !ok { if !ok {
s.publish(path, "external.create", entry.kind) s.publish(path, "external.create", entry.kind)
changed = true
continue continue
} }
if entry.kind == entryFile && (entry.size != old.size || !entry.modTime.Equal(old.modTime)) { if entry.kind == entryFile && (entry.size != old.size || !entry.modTime.Equal(old.modTime)) {
s.publish(path, "external.update", entry.kind) s.publish(path, "external.update", entry.kind)
changed = true
} }
} }
for path, entry := range prev { for path, entry := range prev {
if _, ok := next[path]; !ok { if _, ok := next[path]; !ok {
s.publish(path, "external.delete", entry.kind) s.publish(path, "external.delete", entry.kind)
changed = true
} }
} }
if changed && callback != nil {
callback()
}
} }
func (s *Service) publish(path, operation string, kind entryKind) { func (s *Service) publish(path, operation string, kind entryKind) {
@ -217,8 +237,12 @@ func kindFromInfo(info fs.FileInfo) entryKind {
} }
func isReserved(rel string) bool { func isReserved(rel string) bool {
first := strings.Split(filepath.ToSlash(rel), "/")[0] for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
return strings.EqualFold(first, ".verstak") if strings.EqualFold(segment, ".verstak") {
return true
}
}
return false
} }
func workspaceRoot(path string) string { func workspaceRoot(path string) string {

View File

@ -74,6 +74,26 @@ func TestServiceIgnoresReservedVerstakPaths(t *testing.T) {
} }
} }
func TestServiceCallsChangeCallbackForExternalChanges(t *testing.T) {
root := t.TempDir()
service := NewService(events.NewBus(), 10*time.Millisecond)
changed := make(chan struct{}, 1)
service.SetOnChange(func() { changed <- struct{}{} })
if err := service.Start(root); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(service.Stop)
if err := os.WriteFile(filepath.Join(root, "external.txt"), []byte("change"), 0o644); err != nil {
t.Fatal(err)
}
select {
case <-changed:
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for watcher callback")
}
}
func waitForEvent(t *testing.T, eventCh <-chan events.Event) events.Event { func waitForEvent(t *testing.T, eventCh <-chan events.Event) events.Event {
t.Helper() t.Helper()
select { select {

View File

@ -77,6 +77,12 @@ type Contributions struct {
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"` StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"` OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"` WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"`
WorkspaceTree *ContributionWorkspaceTree `json:"workspaceTree,omitempty"`
}
// ContributionWorkspaceTree represents a singleton workspaceTree contribution.
type ContributionWorkspaceTree struct {
Component string `json:"component"`
} }
// ContributionView represents a view contribution. // ContributionView represents a view contribution.

View File

@ -2,6 +2,8 @@ package sync
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -216,68 +218,147 @@ func (c *Client) Push(ops []Op) (*PushResponse, error) {
// PullRequest is the payload for POST /sync/pull. // PullRequest is the payload for POST /sync/pull.
type PullRequest struct { type PullRequest struct {
SinceSequence int `json:"since_sequence"` SinceSequence int `json:"since_sequence"`
PageLimit int `json:"page_limit,omitempty"`
} }
// PullResponse is the response from POST /sync/pull. // PullResponse is the response from POST /sync/pull.
type PullResponse struct { type PullResponse struct {
ServerSequence int `json:"server_sequence"` ServerSequence int `json:"server_sequence"`
PageLastSequence int `json:"page_last_sequence"`
HasMore bool `json:"has_more"`
Ops []Op `json:"ops"` Ops []Op `json:"ops"`
} }
// BlobReference is the only binary content representation permitted inside a
// sync operation. The bytes travel via the Blob API, never payload_json.
type BlobReference struct {
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// ServerError carries a public stable error code. UI layers map Code to their
// own localized wording and must not rely on a server diagnostic string.
type ServerError struct {
Status int
Code string
}
func (e *ServerError) Error() string {
if e.Code == "" {
return fmt.Sprintf("sync-server:request_failed (HTTP %d)", e.Status)
}
return fmt.Sprintf("sync-server:%s (HTTP %d)", e.Code, e.Status)
}
// Pull fetches remote operations since a given sequence. // Pull fetches remote operations since a given sequence.
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) { func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
req := PullRequest{SinceSequence: sinceSequence} return c.PullPage(sinceSequence, 0)
}
// PullPage fetches one bounded ordered page. The caller advances its durable
// cursor only after every returned operation has applied successfully.
func (c *Client) PullPage(sinceSequence, pageLimit int) (*PullResponse, error) {
req := PullRequest{SinceSequence: sinceSequence, PageLimit: pageLimit}
var resp PullResponse var resp PullResponse
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil { if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
return nil, err return nil, err
} }
// Servers before pull pagination did not include page_last_sequence. Keep
// the desktop compatible during rolling upgrades without ever advancing
// beyond an operation actually present in the response.
if resp.PageLastSequence == 0 && len(resp.Ops) > 0 {
resp.PageLastSequence = resp.Ops[len(resp.Ops)-1].ServerSequence
}
return &resp, nil return &resp, nil
} }
// UploadBlob uploads a file to the server and returns its SHA-256. // UploadBlob streams a local file through a multipart pipe. It keeps the
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) { // process memory bounded even when the file is many times larger than an
var b bytes.Buffer // inline sync payload.
w := multipart.NewWriter(&b) func (c *Client) UploadBlob(localPath string) (BlobReference, error) {
fw, err := w.CreateFormFile("file", filepath.Base(localPath)) info, err := os.Stat(localPath)
if err != nil { if err != nil {
return "", err return BlobReference{}, err
}
if !info.Mode().IsRegular() {
return BlobReference{}, fmt.Errorf("blob source is not a regular file")
}
reader, writer := io.Pipe()
multipartWriter := multipart.NewWriter(writer)
writeDone := make(chan error, 1)
go func() {
defer func() {
_ = writer.Close()
}()
part, err := multipartWriter.CreateFormFile("file", filepath.Base(localPath))
if err == nil {
file, openErr := os.Open(localPath)
if openErr != nil {
err = openErr
} else {
_, err = io.Copy(part, file)
closeErr := file.Close()
if err == nil {
err = closeErr
}
}
}
if closeErr := multipartWriter.Close(); err == nil {
err = closeErr
} }
f, err := os.Open(localPath)
if err != nil { if err != nil {
return "", err _ = writer.CloseWithError(err)
} }
defer f.Close() writeDone <- err
if _, err := io.Copy(fw, f); err != nil { }()
return "", err
}
w.Close()
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b) req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", reader)
if err != nil { if err != nil {
return "", err _ = reader.Close()
return BlobReference{}, err
} }
req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.bearerToken()) req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req) resp, err := c.HTTP.Do(req)
if err != nil { if err != nil {
return "", err _ = reader.Close()
<-writeDone
return BlobReference{}, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
var result struct { writeErr := <-writeDone
SHA256 string `json:"sha256"` if writeErr != nil {
Size int `json:"size"` return BlobReference{}, writeErr
} }
return BlobReference{}, c.readErrorBody(resp, resp.StatusCode)
}
var result BlobReference
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err return BlobReference{}, err
} }
return result.SHA256, nil if err := <-writeDone; err != nil {
return BlobReference{}, err
}
if result.Size != info.Size() || !validBlobSHA256(result.SHA256) {
return BlobReference{}, fmt.Errorf("invalid blob upload response")
}
return result, nil
} }
// DownloadBlob downloads a blob by SHA-256 hash. // DownloadBlob downloads a blob by SHA-256 hash.
func (c *Client) DownloadBlob(sha256, destPath string) error { func (c *Client) DownloadBlob(shaHex, destPath string) error {
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil) return c.DownloadBlobVerified(shaHex, -1, destPath)
}
// DownloadBlobVerified streams to a temporary file, verifies the announced
// hash and size, and only then atomically makes the file visible to the vault.
func (c *Client) DownloadBlobVerified(shaHex string, expectedSize int64, destPath string) error {
if !validBlobSHA256(shaHex) || expectedSize < -1 {
return fmt.Errorf("invalid blob reference")
}
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+shaHex, nil)
if err != nil { if err != nil {
return err return err
} }
@ -289,18 +370,66 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode) return c.readErrorBody(resp, resp.StatusCode)
} }
out, err := os.Create(destPath) if expectedSize >= 0 && resp.ContentLength >= 0 && resp.ContentLength != expectedSize {
return fmt.Errorf("download blob: size mismatch")
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
return err
}
out, err := os.CreateTemp(filepath.Dir(destPath), ".verstak-blob-*")
if err != nil { if err != nil {
return err return err
} }
defer out.Close() tmpPath := out.Name()
_, err = io.Copy(out, resp.Body) cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
hash := sha256.New()
limit := int64(1<<63 - 1)
if expectedSize >= 0 {
limit = expectedSize + 1
}
written, err := io.Copy(io.MultiWriter(out, hash), io.LimitReader(resp.Body, limit))
if err != nil {
_ = out.Close()
return err return err
} }
if expectedSize >= 0 && written != expectedSize {
_ = out.Close()
return fmt.Errorf("download blob: size mismatch")
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != shaHex {
_ = out.Close()
return fmt.Errorf("download blob: SHA-256 mismatch")
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, destPath); err != nil {
return err
}
cleanup = false
return nil
}
func validBlobSHA256(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func minInt(a, b int) int { func minInt(a, b int) int {
if a < b { if a < b {
@ -430,5 +559,11 @@ func (c *Client) readErrorBody(resp *http.Response, statusCode int) error {
if strings.Contains(lower, "<html") || strings.Contains(lower, "<!doctype") { if strings.Contains(lower, "<html") || strings.Contains(lower, "<!doctype") {
return fmt.Errorf("not a Verstak Sync server (HTTP %d)", statusCode) return fmt.Errorf("not a Verstak Sync server (HTTP %d)", statusCode)
} }
return fmt.Errorf("server error (HTTP %d)", statusCode) var payload struct {
Code string `json:"code"`
}
if err := json.Unmarshal([]byte(body), &payload); err == nil && payload.Code != "" {
return &ServerError{Status: statusCode, Code: payload.Code}
}
return &ServerError{Status: statusCode, Code: "request_failed"}
} }

View File

@ -1,9 +1,14 @@
package sync package sync
import ( import (
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
) )
@ -119,3 +124,95 @@ func TestPairDeviceSendsVaultID(t *testing.T) {
t.Fatalf("paired vault ID = %q, want vault-123", pairedVaultID) t.Fatalf("paired vault ID = %q, want vault-123", pairedVaultID)
} }
} }
func TestPullPageReadsPaginationMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/sync/pull" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"server_sequence": 9, "page_last_sequence": 4, "has_more": true,
"ops": []map[string]interface{}{{"op_id": "op-4", "server_sequence": 4, "device_id": "other", "entity_type": "file", "entity_id": "a.bin", "op_type": "update", "payload_json": `{}`, "created_at": "2026-01-01T00:00:00Z"}},
})
}))
defer server.Close()
client := NewClient(server.URL, "", "device", t.TempDir())
client.DeviceToken = "token"
response, err := client.PullPage(2, 2)
if err != nil {
t.Fatal(err)
}
if response.PageLastSequence != 4 || !response.HasMore || response.ServerSequence != 9 {
t.Fatalf("pagination response = %+v", response)
}
}
func TestUploadBlobStreamsMultipartAndChecksReturnedSize(t *testing.T) {
data := []byte("streamed binary payload")
path := filepath.Join(t.TempDir(), "blob.bin")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1024); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
received, err := io.ReadAll(file)
if err != nil || string(received) != string(data) {
http.Error(w, "bad upload", http.StatusBadRequest)
return
}
hash := sha256.Sum256(data)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"sha256": fmt.Sprintf("%x", hash[:]), "size": len(data)})
}))
defer server.Close()
client := NewClient(server.URL, "", "device", t.TempDir())
client.DeviceToken = "token"
ref, err := client.UploadBlob(path)
if err != nil {
t.Fatal(err)
}
if ref.Size != int64(len(data)) {
t.Fatalf("uploaded reference = %+v", ref)
}
}
func TestDownloadBlobVerifiesHashAndLeavesNoCorruptDestination(t *testing.T) {
dest := filepath.Join(t.TempDir(), "received.bin")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "7")
_, _ = w.Write([]byte("corrupt"))
}))
defer server.Close()
client := NewClient(server.URL, "", "device", t.TempDir())
client.DeviceToken = "token"
err := client.DownloadBlobVerified("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 7, dest)
if err == nil {
t.Fatal("corrupt blob was accepted")
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatalf("corrupt destination remains: %v", err)
}
}
func TestClientPreservesStableServerErrorCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
_, _ = w.Write([]byte(`{"error":"internal wording must not reach UI","code":"quota_exceeded"}`))
}))
defer server.Close()
client := NewClient(server.URL, "token", "device", t.TempDir())
err := client.post("/api/v1/sync/push", map[string]string{}, nil)
serverErr, ok := err.(*ServerError)
if !ok || serverErr.Code != "quota_exceeded" || serverErr.Status != http.StatusRequestEntityTooLarge {
t.Fatalf("error = %#v, want quota server error", err)
}
}

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -15,6 +16,7 @@ const (
EntityNote = "note" EntityNote = "note"
EntityFile = "file" EntityFile = "file"
EntityFolder = "folder" EntityFolder = "folder"
EntityWorkspace = "workspace"
EntityAction = "action" EntityAction = "action"
EntityWorklog = "worklog" EntityWorklog = "worklog"
) )
@ -24,6 +26,9 @@ const (
OpUpdate = "update" OpUpdate = "update"
OpDelete = "delete" OpDelete = "delete"
OpMove = "move" OpMove = "move"
OpRename = "rename"
OpTrash = "trash"
OpRestore = "restore"
) )
// Op represents a sync operation. // Op represents a sync operation.
@ -50,6 +55,9 @@ type syncState struct {
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
LastPullSeq int `json:"last_pull_seq"` LastPullSeq int `json:"last_pull_seq"`
LastSyncAt string `json:"last_sync_at"` LastSyncAt string `json:"last_sync_at"`
BootstrapComplete bool `json:"bootstrap_complete"`
LastWarning string `json:"last_warning"`
RemoteVaultID string `json:"remote_vault_id"`
} }
// Service records and manages sync operations using JSON file storage. // Service records and manages sync operations using JSON file storage.
@ -81,6 +89,14 @@ func (s *Service) statePath() string {
return filepath.Join(s.syncDir(), "state.json") return filepath.Join(s.syncDir(), "state.json")
} }
func (s *Service) snapshotPath() string {
return filepath.Join(s.syncDir(), "snapshot.json")
}
func (s *Service) scanJournalPath() string {
return filepath.Join(s.syncDir(), "scan-journal.json")
}
func (s *Service) ensureDir() error { func (s *Service) ensureDir() error {
return os.MkdirAll(s.syncDir(), 0o755) return os.MkdirAll(s.syncDir(), 0o755)
} }
@ -113,11 +129,33 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
CreatedAt: now, CreatedAt: now,
} }
return s.recordOps([]Op{op})
}
// recordOps is idempotent by op ID so a scanner recovery journal can safely
// resume after a crash between recording operations and replacing its snapshot.
func (s *Service) recordOps(newOps []Op) error {
if err := s.ensureDir(); err != nil {
return err
}
ops, err := s.loadOps() ops, err := s.loadOps()
if err != nil { if err != nil {
return err return err
} }
existing := make(map[string]bool, len(ops))
for _, op := range ops {
existing[op.OpID] = true
}
for _, op := range newOps {
if op.OpID == "" || existing[op.OpID] {
continue
}
if op.ID == "" {
op.ID = op.OpID
}
ops = append(ops, op) ops = append(ops, op)
existing[op.OpID] = true
}
return s.saveOps(ops) return s.saveOps(ops)
} }
@ -160,6 +198,42 @@ func (s *Service) GetUnpushedOps() ([]Op, error) {
return unpushed, nil return unpushed, nil
} }
// HasUnpushedPath reports whether a local operation still owns a path (or one
// of its descendants). Pull uses it to turn an incoming overwrite/delete into
// a visible conflict instead of silently replacing a local external edit.
func (s *Service) HasUnpushedPath(path string) (bool, error) {
ops, err := s.GetUnpushedOps()
if err != nil {
return false, err
}
for _, op := range ops {
if syncPathsOverlap(path, op.EntityID) {
return true, nil
}
var payload struct {
Path string `json:"path"`
FromPath string `json:"fromPath"`
ToPath string `json:"toPath"`
}
if op.PayloadJSON == "" || json.Unmarshal([]byte(op.PayloadJSON), &payload) != nil {
continue
}
if syncPathsOverlap(path, payload.Path) || syncPathsOverlap(path, payload.FromPath) || syncPathsOverlap(path, payload.ToPath) {
return true, nil
}
}
return false, nil
}
func syncPathsOverlap(left, right string) bool {
left = strings.Trim(left, "/")
right = strings.Trim(right, "/")
if left == "" || right == "" {
return false
}
return left == right || strings.HasPrefix(left, right+"/") || strings.HasPrefix(right, left+"/")
}
// MarkPushed marks ops as pushed to server. // MarkPushed marks ops as pushed to server.
func (s *Service) MarkPushed(opIDs []string) error { func (s *Service) MarkPushed(opIDs []string) error {
ops, err := s.loadOps() ops, err := s.loadOps()
@ -244,6 +318,66 @@ func (s *Service) SetLastSyncAt(t string) error {
return s.saveState(st) return s.saveState(st)
} }
// BootstrapComplete reports whether the initial pull/reconcile/bootstrap cycle
// finished successfully for this vault connection.
func (s *Service) BootstrapComplete() (bool, error) {
st, err := s.loadState()
if err != nil {
return false, err
}
return st.BootstrapComplete, nil
}
// SetBootstrapComplete marks the initial reconciliation as complete only after
// all remote operations were applied and the local initial snapshot was queued.
func (s *Service) SetBootstrapComplete(done bool) error {
st, err := s.loadState()
if err != nil {
return err
}
st.BootstrapComplete = done
return s.saveState(st)
}
// LastWarning returns the persistent scanner warning shown by sync status.
func (s *Service) LastWarning() (string, error) {
st, err := s.loadState()
if err != nil {
return "", err
}
return st.LastWarning, nil
}
// SetLastWarning persists an unresolved scanner condition. An empty string
// clears the warning once a later complete scan no longer reports it.
func (s *Service) SetLastWarning(message string) error {
st, err := s.loadState()
if err != nil {
return err
}
st.LastWarning = message
return s.saveState(st)
}
// RemoteVaultID returns the optional target vault chosen while pairing a new
// local vault for restore. Empty means this vault's own durable ID was used.
func (s *Service) RemoteVaultID() (string, error) {
st, err := s.loadState()
if err != nil {
return "", err
}
return st.RemoteVaultID, nil
}
func (s *Service) SetRemoteVaultID(vaultID string) error {
st, err := s.loadState()
if err != nil {
return err
}
st.RemoteVaultID = vaultID
return s.saveState(st)
}
// GetDeviceID returns the device ID used by this service. // GetDeviceID returns the device ID used by this service.
func (s *Service) GetDeviceID() string { func (s *Service) GetDeviceID() string {
return s.deviceID return s.deviceID
@ -288,7 +422,7 @@ func (s *Service) saveOps(ops []Op) error {
if err != nil { if err != nil {
return fmt.Errorf("marshal ops: %w", err) return fmt.Errorf("marshal ops: %w", err)
} }
return os.WriteFile(s.opsPath(), data, 0o644) return atomicWriteFile(s.opsPath(), data, 0o600)
} }
func (s *Service) loadState() (*syncState, error) { func (s *Service) loadState() (*syncState, error) {
@ -311,5 +445,42 @@ func (s *Service) saveState(st *syncState) error {
if err != nil { if err != nil {
return fmt.Errorf("marshal state: %w", err) return fmt.Errorf("marshal state: %w", err)
} }
return os.WriteFile(s.statePath(), data, 0o644) return atomicWriteFile(s.statePath(), data, 0o600)
}
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".verstak-sync-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
cleanup = false
return nil
} }

View File

@ -0,0 +1,854 @@
package sync
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/google/uuid"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
)
const snapshotVersion = 1
// maxOperationFileBytes is an explicit desktop-side safety ceiling. Binary
// content is streamed through the Blob API rather than embedded in operations,
// so it is intentionally higher than the plugin Files API read limit.
const maxOperationFileBytes int64 = 256 * 1024 * 1024
// BlobCachePath is core-private durable staging for a local operation's
// immutable binary content. It remains excluded from ordinary file sync.
func BlobCachePath(vaultRoot, hash string) string {
return filepath.Join(vaultRoot, ".verstak", "sync", "blobs", hash)
}
// Snapshot is the durable local view of the synchronizable part of a vault.
// Its entries are only files and folders whose latest state was successfully
// represented by an operation or intentionally accepted as an initial baseline.
type Snapshot struct {
Version int `json:"version"`
Entries map[string]SnapshotEntry `json:"entries"`
Workspaces map[string]WorkspaceSnapshot `json:"workspaces,omitempty"`
TrashedWorkspaces map[string]WorkspaceSnapshot `json:"trashedWorkspaces,omitempty"`
WorkspacesInitialized bool `json:"workspacesInitialized,omitempty"`
Unresolved map[string]string `json:"unresolved,omitempty"`
}
// SnapshotEntry stores only stable filesystem facts needed for reconciliation.
type SnapshotEntry struct {
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
Hash string `json:"hash,omitempty"`
}
// WorkspaceSnapshot keeps the core-owned identity and creation metadata of a
// top-level workspace. The marker itself remains excluded from normal file
// sync and is never exposed through the Files API.
type WorkspaceSnapshot struct {
WorkspaceID string `json:"workspaceId"`
Path string `json:"path"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Entries map[string]SnapshotEntry `json:"entries,omitempty"`
}
type scanJournal struct {
Snapshot Snapshot `json:"snapshot"`
Ops []Op `json:"ops"`
}
type scannedVault struct {
Entries map[string]SnapshotEntry
Workspaces map[string]WorkspaceSnapshot
Unresolved map[string]string
}
// LoadSnapshot returns the current durable scanner snapshot. A missing
// snapshot is represented by an empty snapshot, which is useful to callers
// that only need to inspect it.
func (s *Service) LoadSnapshot() (Snapshot, error) {
snapshot, exists, err := s.loadSnapshot()
if err != nil {
return Snapshot{}, err
}
if !exists {
return newSnapshot(), nil
}
return snapshot, nil
}
// ScanAndRecord scans the whole synchronizable vault and records exactly the
// detected local changes. On its first run it writes a baseline and deliberately
// produces no operations; bootstrap decides what can safely be published.
func (s *Service) ScanAndRecord() ([]string, error) {
if err := s.recoverScanJournal(); err != nil {
return nil, err
}
previous, exists, err := s.loadSnapshot()
if err != nil {
return nil, err
}
current, warnings, err := scanVault(s.vaultRoot, previous)
if err != nil {
return nil, err
}
next := snapshotFromScan(current, previous, exists)
if !exists {
if err := s.saveSnapshot(next); err != nil {
return nil, err
}
return warnings, nil
}
ops, next, err := diffSnapshots(previous, next, s.deviceID, s.vaultRoot)
if err != nil {
return nil, err
}
if len(ops) == 0 {
if err := s.saveSnapshot(next); err != nil {
return nil, err
}
return warnings, nil
}
if err := s.commitScanTransaction(next, ops); err != nil {
return nil, err
}
return warnings, nil
}
// RecordBootstrapOps records creates for a pre-pull local snapshot. It is used
// only after a successful initial pull, so an empty new vault never turns into
// remote delete operations. Callers may pass the snapshot captured before the
// pull; remote-only entries added during reconciliation are therefore not
// reflected back to the server as local creates.
func (s *Service) RecordBootstrapOps(initial Snapshot) error {
if err := s.recoverScanJournal(); err != nil {
return err
}
current, exists, err := s.loadSnapshot()
if err != nil {
return err
}
if !exists {
return fmt.Errorf("bootstrap requires an initial snapshot")
}
ops, _, err := diffSnapshots(newSnapshot(), initial, s.deviceID, s.vaultRoot)
if err != nil {
return err
}
existing, err := s.GetUnpushedOps()
if err != nil {
return err
}
existingCreates := make(map[string]bool, len(existing))
for _, op := range existing {
if op.OpType == OpCreate {
existingCreates[op.EntityType+"\x00"+op.EntityID] = true
}
}
filtered := ops[:0]
for _, op := range ops {
if op.OpType == OpCreate && existingCreates[op.EntityType+"\x00"+op.EntityID] {
continue
}
filtered = append(filtered, op)
}
ops = filtered
if len(ops) == 0 {
return nil
}
return s.commitScanTransaction(current, ops)
}
// RebaseSnapshot accepts filesystem changes that were applied from a remote
// operation without producing any outgoing operation for them.
func (s *Service) RebaseSnapshot() ([]string, error) {
if err := s.recoverScanJournal(); err != nil {
return nil, err
}
previous, exists, err := s.loadSnapshot()
if err != nil {
return nil, err
}
current, warnings, err := scanVault(s.vaultRoot, previous)
if err != nil {
return nil, err
}
next := snapshotFromScan(current, previous, exists)
acceptWorkspaceLifecycle(previous, &next)
if err := s.saveSnapshot(next); err != nil {
return nil, err
}
return warnings, nil
}
func newSnapshot() Snapshot {
return Snapshot{
Version: snapshotVersion,
Entries: make(map[string]SnapshotEntry),
Workspaces: make(map[string]WorkspaceSnapshot),
TrashedWorkspaces: make(map[string]WorkspaceSnapshot),
WorkspacesInitialized: true,
Unresolved: make(map[string]string),
}
}
func (s *Service) loadSnapshot() (Snapshot, bool, error) {
data, err := os.ReadFile(s.snapshotPath())
if err != nil {
if os.IsNotExist(err) {
return Snapshot{}, false, nil
}
return Snapshot{}, false, fmt.Errorf("read snapshot: %w", err)
}
var snapshot Snapshot
if err := json.Unmarshal(data, &snapshot); err != nil {
return Snapshot{}, false, fmt.Errorf("parse snapshot: %w", err)
}
if snapshot.Version != snapshotVersion {
return Snapshot{}, false, fmt.Errorf("unsupported snapshot version: %d", snapshot.Version)
}
if snapshot.Entries == nil {
snapshot.Entries = make(map[string]SnapshotEntry)
}
if snapshot.Workspaces == nil {
snapshot.Workspaces = make(map[string]WorkspaceSnapshot)
}
if snapshot.TrashedWorkspaces == nil {
snapshot.TrashedWorkspaces = make(map[string]WorkspaceSnapshot)
}
if snapshot.Unresolved == nil {
snapshot.Unresolved = make(map[string]string)
}
return snapshot, true, nil
}
func (s *Service) saveSnapshot(snapshot Snapshot) error {
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return fmt.Errorf("marshal snapshot: %w", err)
}
return atomicWriteFile(s.snapshotPath(), data, 0o600)
}
func (s *Service) loadScanJournal() (scanJournal, bool, error) {
data, err := os.ReadFile(s.scanJournalPath())
if err != nil {
if os.IsNotExist(err) {
return scanJournal{}, false, nil
}
return scanJournal{}, false, fmt.Errorf("read scan journal: %w", err)
}
var journal scanJournal
if err := json.Unmarshal(data, &journal); err != nil {
return scanJournal{}, false, fmt.Errorf("parse scan journal: %w", err)
}
return journal, true, nil
}
func (s *Service) saveScanJournal(journal scanJournal) error {
data, err := json.MarshalIndent(journal, "", " ")
if err != nil {
return fmt.Errorf("marshal scan journal: %w", err)
}
return atomicWriteFile(s.scanJournalPath(), data, 0o600)
}
func (s *Service) recoverScanJournal() error {
journal, exists, err := s.loadScanJournal()
if err != nil || !exists {
return err
}
if err := s.recordOps(journal.Ops); err != nil {
return err
}
if err := s.saveSnapshot(journal.Snapshot); err != nil {
return err
}
if err := os.Remove(s.scanJournalPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (s *Service) commitScanTransaction(snapshot Snapshot, ops []Op) error {
journal := scanJournal{Snapshot: snapshot, Ops: ops}
if err := s.saveScanJournal(journal); err != nil {
return err
}
if err := s.recordOps(ops); err != nil {
return err
}
if err := s.saveSnapshot(snapshot); err != nil {
return err
}
if err := os.Remove(s.scanJournalPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func scanVault(root string, previous Snapshot) (scannedVault, []string, error) {
result := scannedVault{
Entries: make(map[string]SnapshotEntry),
Workspaces: make(map[string]WorkspaceSnapshot),
Unresolved: make(map[string]string),
}
var warnings []string
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == root {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if excludedFromSync(rel) {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
if info.IsDir() {
result.Entries[rel] = SnapshotEntry{
Path: rel,
Type: EntityFolder,
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
}
return nil
}
if !info.Mode().IsRegular() {
return nil
}
if info.Size() > maxOperationFileBytes {
message := fmt.Sprintf("file-too-large: %s (%d bytes exceeds %d bytes)", rel, info.Size(), maxOperationFileBytes)
result.Unresolved[rel] = message
warnings = append(warnings, message)
return nil
}
hash, err := sha256File(path)
if err != nil {
return fmt.Errorf("hash %s: %w", rel, err)
}
result.Entries[rel] = SnapshotEntry{
Path: rel,
Type: EntityFile,
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
Hash: hash,
}
return nil
})
if err != nil {
return scannedVault{}, nil, err
}
workspaces, workspaceWarnings, err := scanWorkspaceSnapshots(root, previous.Workspaces)
if err != nil {
return scannedVault{}, nil, err
}
for workspaceID, workspace := range workspaces {
result.Workspaces[workspaceID] = workspace
}
for _, warning := range workspaceWarnings {
warnings = append(warnings, warning)
if strings.HasPrefix(warning, "duplicate-workspace-id: ") {
path := strings.TrimPrefix(warning, "duplicate-workspace-id: ")
result.Unresolved[path] = warning
removeEntriesUnder(result.Entries, path)
}
}
sort.Strings(warnings)
return result, warnings, nil
}
func scanWorkspaceSnapshots(root string, preferred map[string]WorkspaceSnapshot) (map[string]WorkspaceSnapshot, []string, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, nil, err
}
candidates := make(map[string][]WorkspaceSnapshot)
var warnings []string
for _, entry := range entries {
if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || strings.EqualFold(entry.Name(), ".verstak") {
continue
}
path := filepath.Join(root, entry.Name(), ".verstak", "workspace.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, nil, fmt.Errorf("read workspace marker %s: %w", entry.Name(), err)
}
var marker struct {
WorkspaceID string `json:"workspaceId"`
}
if err := json.Unmarshal(data, &marker); err != nil {
warnings = append(warnings, fmt.Sprintf("invalid-workspace-id: %s", entry.Name()))
continue
}
if _, err := uuid.Parse(marker.WorkspaceID); err != nil {
warnings = append(warnings, fmt.Sprintf("invalid-workspace-id: %s", entry.Name()))
continue
}
metadata, err := readWorkspaceMetadataSnapshot(root, entry.Name())
if err != nil {
return nil, nil, err
}
candidates[marker.WorkspaceID] = append(candidates[marker.WorkspaceID], WorkspaceSnapshot{
WorkspaceID: marker.WorkspaceID,
Path: entry.Name(),
Metadata: metadata,
})
}
workspaces := make(map[string]WorkspaceSnapshot, len(candidates))
for workspaceID, choices := range candidates {
sort.Slice(choices, func(i, j int) bool { return choices[i].Path < choices[j].Path })
selected := choices[0]
if old, ok := preferred[workspaceID]; ok {
for _, choice := range choices {
if choice.Path == old.Path {
selected = choice
break
}
}
}
workspaces[workspaceID] = selected
for _, choice := range choices {
if choice.Path != selected.Path {
warnings = append(warnings, "duplicate-workspace-id: "+choice.Path)
}
}
}
return workspaces, warnings, nil
}
func readWorkspaceMetadataSnapshot(root, name string) (json.RawMessage, error) {
path := filepath.Join(root, ".verstak", "workspaces", name, "metadata.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read workspace metadata %s: %w", name, err)
}
if !json.Valid(data) {
return nil, fmt.Errorf("invalid workspace metadata: %s", name)
}
return json.RawMessage(append([]byte(nil), data...)), nil
}
func excludedFromSync(rel string) bool {
rel = filepath.ToSlash(rel)
for _, segment := range strings.Split(rel, "/") {
if strings.EqualFold(segment, ".verstak") {
return true
}
}
base := filepath.Base(rel)
return strings.HasPrefix(base, ".verstak-write-") || strings.HasSuffix(base, ".tmp") || strings.HasSuffix(base, ".swp") || strings.HasSuffix(base, "~")
}
func sha256File(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func snapshotFromScan(current scannedVault, previous Snapshot, previousExists bool) Snapshot {
next := newSnapshot()
for path, entry := range current.Entries {
next.Entries[path] = entry
}
for workspaceID, workspace := range current.Workspaces {
next.Workspaces[workspaceID] = workspace
}
for workspaceID, workspace := range previous.TrashedWorkspaces {
next.TrashedWorkspaces[workspaceID] = workspace
}
for path, message := range current.Unresolved {
next.Unresolved[path] = message
copyEntriesUnder(next.Entries, previous.Entries, path)
}
if !previousExists {
return next
}
for path, message := range previous.Unresolved {
if _, supported := current.Entries[path]; supported {
continue
}
if _, stillUnsupported := current.Unresolved[path]; stillUnsupported {
continue
}
next.Unresolved[path] = "unresolved sync file disappeared before it could be synchronized: " + message
copyEntriesUnder(next.Entries, previous.Entries, path)
}
return next
}
func diffSnapshots(previous, next Snapshot, deviceID, vaultRoot string) ([]Op, Snapshot, error) {
workspaceOps, err := diffWorkspaceSnapshots(&previous, &next, deviceID)
if err != nil {
return nil, next, err
}
var createsOrUpdates []Op
var deletes []Op
for path, entry := range next.Entries {
old, existed := previous.Entries[path]
if existed && entriesEqual(old, entry) {
continue
}
if unresolvedPath(next.Unresolved, path) {
continue
}
opType := OpCreate
if existed {
opType = OpUpdate
}
payload, err := payloadForEntry(vaultRoot, entry)
if err != nil {
return nil, next, err
}
createsOrUpdates = append(createsOrUpdates, newSnapshotOp(deviceID, entry.Type, path, opType, payload))
}
for path, old := range previous.Entries {
if _, exists := next.Entries[path]; exists {
continue
}
if unresolvedPath(previous.Unresolved, path) {
continue
}
deletes = append(deletes, newSnapshotOp(deviceID, old.Type, path, OpDelete, map[string]string{"path": path}))
}
sort.Slice(createsOrUpdates, func(i, j int) bool {
left, right := createsOrUpdates[i], createsOrUpdates[j]
leftDepth, rightDepth := pathDepth(left.EntityID), pathDepth(right.EntityID)
if leftDepth != rightDepth {
return leftDepth < rightDepth
}
if left.EntityType != right.EntityType {
return left.EntityType == EntityFolder
}
return left.EntityID < right.EntityID
})
sort.Slice(deletes, func(i, j int) bool {
left, right := deletes[i], deletes[j]
leftDepth, rightDepth := pathDepth(left.EntityID), pathDepth(right.EntityID)
if leftDepth != rightDepth {
return leftDepth > rightDepth
}
if left.EntityType != right.EntityType {
return left.EntityType == EntityFile
}
return left.EntityID < right.EntityID
})
return append(workspaceOps, append(createsOrUpdates, deletes...)...), next, nil
}
func diffWorkspaceSnapshots(previous, next *Snapshot, deviceID string) ([]Op, error) {
var ops []Op
if !previous.WorkspacesInitialized {
return ops, nil
}
for workspaceID, oldWorkspace := range previous.Workspaces {
currentWorkspace, active := next.Workspaces[workspaceID]
if active {
if currentWorkspace.Path != oldWorkspace.Path {
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpRename, currentWorkspace, oldWorkspace.Path))
remapEntriesPrefix(previous.Entries, oldWorkspace.Path, currentWorkspace.Path)
}
continue
}
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpTrash, oldWorkspace, ""))
oldWorkspace.Entries = entriesUnder(previous.Entries, oldWorkspace.Path)
removeEntriesUnder(previous.Entries, oldWorkspace.Path)
next.TrashedWorkspaces[workspaceID] = oldWorkspace
}
for workspaceID, currentWorkspace := range next.Workspaces {
if _, alreadyActive := previous.Workspaces[workspaceID]; alreadyActive {
continue
}
if trashedWorkspace, wasTrashed := previous.TrashedWorkspaces[workspaceID]; wasTrashed {
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpRestore, currentWorkspace, ""))
copyRemappedEntries(previous.Entries, trashedWorkspace.Entries, trashedWorkspace.Path, currentWorkspace.Path)
delete(next.TrashedWorkspaces, workspaceID)
continue
}
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpCreate, currentWorkspace, ""))
delete(next.Entries, currentWorkspace.Path)
}
sort.Slice(ops, func(i, j int) bool {
if ops[i].OpType != ops[j].OpType {
return workspaceOpOrder(ops[i].OpType) < workspaceOpOrder(ops[j].OpType)
}
return ops[i].EntityID < ops[j].EntityID
})
return ops, nil
}
func workspaceOpOrder(opType string) int {
switch opType {
case OpCreate:
return 0
case OpRename:
return 1
case OpRestore:
return 2
case OpTrash:
return 3
default:
return 4
}
}
type snapshotWorkspacePayload struct {
WorkspaceID string `json:"workspaceId"`
Path string `json:"path"`
PreviousPath string `json:"previousPath,omitempty"`
Name string `json:"name"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
func newWorkspaceSnapshotOp(deviceID, workspaceID, opType string, workspace WorkspaceSnapshot, previousPath string) Op {
payload, _ := json.Marshal(snapshotWorkspacePayload{
WorkspaceID: workspaceID,
Path: workspace.Path,
PreviousPath: previousPath,
Name: workspace.Path,
Metadata: workspace.Metadata,
})
return newSnapshotOp(deviceID, EntityWorkspace, workspaceID, opType, json.RawMessage(payload))
}
func acceptWorkspaceLifecycle(previous Snapshot, next *Snapshot) {
for workspaceID, oldWorkspace := range previous.Workspaces {
if _, stillActive := next.Workspaces[workspaceID]; !stillActive {
oldWorkspace.Entries = entriesUnder(previous.Entries, oldWorkspace.Path)
next.TrashedWorkspaces[workspaceID] = oldWorkspace
}
}
for workspaceID := range next.Workspaces {
delete(next.TrashedWorkspaces, workspaceID)
}
}
func unresolvedPath(unresolved map[string]string, path string) bool {
for unresolvedPath := range unresolved {
if path == unresolvedPath || strings.HasPrefix(path, unresolvedPath+"/") {
return true
}
}
return false
}
func copyEntriesUnder(destination, source map[string]SnapshotEntry, root string) {
for path, entry := range source {
if path == root || strings.HasPrefix(path, root+"/") {
destination[path] = entry
}
}
}
func removeEntriesUnder(entries map[string]SnapshotEntry, root string) {
for path := range entries {
if path == root || strings.HasPrefix(path, root+"/") {
delete(entries, path)
}
}
}
func remapEntriesPrefix(entries map[string]SnapshotEntry, oldPrefix, newPrefix string) {
type remappedEntry struct {
oldPath string
entry SnapshotEntry
}
var remapped []remappedEntry
for path, entry := range entries {
if path != oldPrefix && !strings.HasPrefix(path, oldPrefix+"/") {
continue
}
suffix := strings.TrimPrefix(path, oldPrefix)
entry.Path = newPrefix + suffix
remapped = append(remapped, remappedEntry{oldPath: path, entry: entry})
}
for _, item := range remapped {
delete(entries, item.oldPath)
entries[item.entry.Path] = item.entry
}
}
func entriesUnder(entries map[string]SnapshotEntry, root string) map[string]SnapshotEntry {
result := make(map[string]SnapshotEntry)
copyEntriesUnder(result, entries, root)
return result
}
func copyRemappedEntries(destination, source map[string]SnapshotEntry, oldPrefix, newPrefix string) {
for path, entry := range source {
if path != oldPrefix && !strings.HasPrefix(path, oldPrefix+"/") {
continue
}
suffix := strings.TrimPrefix(path, oldPrefix)
entry.Path = newPrefix + suffix
destination[entry.Path] = entry
}
}
func entriesEqual(left, right SnapshotEntry) bool {
return left.Type == right.Type && left.Size == right.Size && left.Hash == right.Hash
}
func payloadForEntry(vaultRoot string, entry SnapshotEntry) (map[string]interface{}, error) {
payload := map[string]interface{}{"path": entry.Path, "contentHash": entry.Hash}
if entry.Type == EntityFolder {
return payload, nil
}
path := filepath.Join(vaultRoot, filepath.FromSlash(entry.Path))
if hash, err := sha256File(path); err != nil {
return nil, err
} else if hash != entry.Hash {
return nil, fmt.Errorf("file changed during scan: %s", entry.Path)
}
if entry.Size > corefiles.MaxTextFileBytes {
if err := cacheBlob(path, BlobCachePath(vaultRoot, entry.Hash), entry.Hash); err != nil {
return nil, err
}
payload["blob"] = map[string]interface{}{"sha256": entry.Hash, "size": entry.Size}
return payload, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if isSyncText(data) {
payload["content"] = string(data)
return payload, nil
}
if err := cacheBlob(path, BlobCachePath(vaultRoot, entry.Hash), entry.Hash); err != nil {
return nil, err
}
payload["blob"] = map[string]interface{}{"sha256": entry.Hash, "size": entry.Size}
return payload, nil
}
func cacheBlob(source, destination, wantHash string) error {
if info, err := os.Lstat(destination); err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("blob cache target is not a regular file")
}
if hash, err := sha256File(destination); err == nil && hash == wantHash {
return nil
}
if err := os.Remove(destination); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
tmp, err := os.CreateTemp(filepath.Dir(destination), ".blob-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
hash := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmp, hash), in); err != nil {
_ = tmp.Close()
return err
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != wantHash {
_ = tmp.Close()
return fmt.Errorf("file changed during blob staging")
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, destination); err != nil {
return err
}
cleanup = false
return nil
}
func newSnapshotOp(deviceID, entityType, entityID, opType string, payload interface{}) Op {
data, _ := json.Marshal(payload)
id := uuid.NewString()
return Op{
ID: id,
OpID: id,
DeviceID: deviceID,
EntityType: entityType,
EntityID: entityID,
OpType: opType,
PayloadJSON: string(data),
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
}
func pathDepth(path string) int {
if path == "" {
return 0
}
return strings.Count(path, "/") + 1
}
func isSyncText(data []byte) bool {
if !utf8.Valid(data) {
return false
}
for _, r := range string(data) {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
return false
}
}
return true
}

View File

@ -0,0 +1,445 @@
package sync
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/google/uuid"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
)
func TestScanAndRecordTracksExternalWorkspaceLifecycleByIdentity(t *testing.T) {
root := t.TempDir()
workspaceID := uuid.NewString()
createSnapshotWorkspace(t, root, "Project", workspaceID)
if err := os.Mkdir(filepath.Join(root, "Project", "Files"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Project", "Files", "note.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("baseline: %v", err)
}
if err := os.Rename(filepath.Join(root, "Project"), filepath.Join(root, "Renamed")); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan rename: %v", err)
}
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 0, OpRename, workspaceID, "Renamed", "Project")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("unchanged renamed scan: %v", err)
}
if got := len(unpushedOps(t, service)); got != 1 {
t.Fatalf("unchanged rename produced %d operations, want 1", got)
}
trashPath := filepath.Join(root, ".verstak", "trash", "workspaces", "external-trash", "Renamed")
if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(filepath.Join(root, "Renamed"), trashPath); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan trash: %v", err)
}
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 1, OpTrash, workspaceID, "Renamed", "")
if err := os.Rename(trashPath, filepath.Join(root, "Restored")); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan restore: %v", err)
}
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 2, OpRestore, workspaceID, "Restored", "")
createSnapshotWorkspace(t, root, "Copied", workspaceID)
warnings, err := service.ScanAndRecord()
if err != nil {
t.Fatalf("scan duplicate identity: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "duplicate-workspace-id: Copied") {
t.Fatalf("duplicate identity warnings = %v", warnings)
}
if got := len(unpushedOps(t, service)); got != 3 {
t.Fatalf("duplicate identity created operations: %d, want 3", got)
}
}
func createSnapshotWorkspace(t *testing.T, root, name, workspaceID string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, name, ".verstak"), 0o755); err != nil {
t.Fatal(err)
}
marker, err := json.Marshal(map[string]string{"workspaceId": workspaceID})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, name, ".verstak", "workspace.json"), marker, 0o600); err != nil {
t.Fatal(err)
}
metadataPath := filepath.Join(root, ".verstak", "workspaces", name, "metadata.json")
if err := os.MkdirAll(filepath.Dir(metadataPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(metadataPath, []byte(`{"workspaceId":"`+workspaceID+`","workspaceName":"`+name+`"}`), 0o600); err != nil {
t.Fatal(err)
}
}
func assertWorkspaceSnapshotOp(t *testing.T, ops []Op, index int, opType, workspaceID, path, previousPath string) {
t.Helper()
if len(ops) <= index {
t.Fatalf("operations = %#v, want index %d", ops, index)
}
op := ops[index]
if op.EntityType != EntityWorkspace || op.EntityID != workspaceID || op.OpType != opType {
t.Fatalf("workspace operation = %+v", op)
}
var payload snapshotWorkspacePayload
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
t.Fatalf("decode workspace payload: %v", err)
}
if payload.Path != path || payload.PreviousPath != previousPath || payload.WorkspaceID != workspaceID {
t.Fatalf("workspace payload = %+v", payload)
}
}
func TestScanAndRecordBaselinesThenRecordsExternalChanges(t *testing.T) {
root := t.TempDir()
service := NewService(root, "device-a")
warnings, err := service.ScanAndRecord()
if err != nil {
t.Fatalf("initial ScanAndRecord: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("initial warnings = %v, want none", warnings)
}
assertUnpushedCount(t, service, 0)
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Docs", "note.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
warnings, err = service.ScanAndRecord()
if err != nil {
t.Fatalf("scan create: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("create warnings = %v", warnings)
}
ops := unpushedOps(t, service)
if len(ops) != 2 {
t.Fatalf("create ops = %#v, want folder and file", ops)
}
if ops[0].EntityType != EntityFolder || ops[0].EntityID != "Docs" || ops[0].OpType != OpCreate {
t.Fatalf("folder op = %+v", ops[0])
}
if ops[1].EntityType != EntityFile || ops[1].EntityID != "Docs/note.txt" || ops[1].OpType != OpCreate {
t.Fatalf("file op = %+v", ops[1])
}
var createPayload map[string]interface{}
if err := json.Unmarshal([]byte(ops[1].PayloadJSON), &createPayload); err != nil {
t.Fatalf("decode file payload: %v", err)
}
if createPayload["content"] != "one" || createPayload["contentHash"] == "" {
t.Fatalf("file payload = %#v", createPayload)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("unchanged scan: %v", err)
}
assertUnpushedCount(t, service, 2)
if err := os.WriteFile(filepath.Join(root, "Docs", "note.txt"), []byte("two"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan update: %v", err)
}
ops = unpushedOps(t, service)
if len(ops) != 3 || ops[2].EntityType != EntityFile || ops[2].OpType != OpUpdate {
t.Fatalf("update ops = %#v", ops)
}
if err := os.Remove(filepath.Join(root, "Docs", "note.txt")); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(root, "Docs")); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan delete: %v", err)
}
ops = unpushedOps(t, service)
if len(ops) != 5 {
t.Fatalf("delete ops = %#v", ops)
}
if ops[3].EntityType != EntityFile || ops[3].OpType != OpDelete || ops[4].EntityType != EntityFolder || ops[4].OpType != OpDelete {
t.Fatalf("delete ordering = %#v", ops[3:])
}
}
func TestSnapshotUsesBlobReferenceForBinaryFileBeyondInlineLimit(t *testing.T) {
root := t.TempDir()
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("baseline: %v", err)
}
data := make([]byte, corefiles.MaxBinaryReadBytes+1)
for i := range data {
data[i] = byte(i % 251)
}
if err := os.WriteFile(filepath.Join(root, "large.bin"), data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan binary: %v", err)
}
ops := unpushedOps(t, service)
if len(ops) != 1 {
t.Fatalf("operations = %#v, want one file create", ops)
}
var payload struct {
DataBase64 *string `json:"dataBase64"`
Blob *struct {
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
} `json:"blob"`
}
if err := json.Unmarshal([]byte(ops[0].PayloadJSON), &payload); err != nil {
t.Fatal(err)
}
if payload.DataBase64 != nil || payload.Blob == nil || payload.Blob.Size != int64(len(data)) || payload.Blob.SHA256 == "" {
t.Fatalf("binary sync payload = %s, want blob reference without base64", ops[0].PayloadJSON)
}
}
func TestScanAndRecordNeverTreatsInitialFilesAsDeletes(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "Existing"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Existing", "before-sync.txt"), []byte("local"), 0o644); err != nil {
t.Fatal(err)
}
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("initial ScanAndRecord: %v", err)
}
assertUnpushedCount(t, service, 0)
snapshot, err := service.LoadSnapshot()
if err != nil {
t.Fatalf("LoadSnapshot: %v", err)
}
if snapshot.Entries["Existing/before-sync.txt"].Hash == "" {
t.Fatalf("snapshot = %#v, expected content hash", snapshot.Entries)
}
}
func TestScanAndRecordFindsChangesMadeWhileDesktopWasClosed(t *testing.T) {
root := t.TempDir()
initial := NewService(root, "device-a")
if _, err := initial.ScanAndRecord(); err != nil {
t.Fatalf("initial baseline: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "offline.txt"), []byte("created while closed"), 0o644); err != nil {
t.Fatal(err)
}
restarted := NewService(root, "device-a")
if _, err := restarted.ScanAndRecord(); err != nil {
t.Fatalf("scan after offline create: %v", err)
}
ops := unpushedOps(t, restarted)
if len(ops) != 1 || ops[0].OpType != OpCreate || ops[0].EntityID != "offline.txt" {
t.Fatalf("offline create operations = %#v", ops)
}
if err := os.WriteFile(filepath.Join(root, "offline.txt"), []byte("updated while closed"), 0o644); err != nil {
t.Fatal(err)
}
restarted = NewService(root, "device-a")
if _, err := restarted.ScanAndRecord(); err != nil {
t.Fatalf("scan after offline update: %v", err)
}
if err := os.Remove(filepath.Join(root, "offline.txt")); err != nil {
t.Fatal(err)
}
restarted = NewService(root, "device-a")
if _, err := restarted.ScanAndRecord(); err != nil {
t.Fatalf("scan after offline delete: %v", err)
}
ops = unpushedOps(t, restarted)
if len(ops) != 3 || ops[1].OpType != OpUpdate || ops[2].OpType != OpDelete {
t.Fatalf("offline lifecycle operations = %#v", ops)
}
}
func TestRecordBootstrapOpsPublishesExistingFilesWithoutDeletes(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "Existing"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Existing", "before-sync.txt"), []byte("local"), 0o644); err != nil {
t.Fatal(err)
}
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatal(err)
}
initial, err := service.LoadSnapshot()
if err != nil {
t.Fatal(err)
}
if err := service.RecordBootstrapOps(initial); err != nil {
t.Fatalf("RecordBootstrapOps: %v", err)
}
ops := unpushedOps(t, service)
if len(ops) != 2 {
t.Fatalf("bootstrap ops = %#v, want create folder and file", ops)
}
for _, op := range ops {
if op.OpType != OpCreate {
t.Fatalf("bootstrap op = %+v, initial scan must not create delete", op)
}
}
empty := newSnapshot()
if err := service.RecordBootstrapOps(empty); err != nil {
t.Fatalf("empty bootstrap: %v", err)
}
if got := len(unpushedOps(t, service)); got != 2 {
t.Fatalf("empty bootstrap added operations = %d, want 2", got)
}
}
func TestSyncBootstrapAndWarningStateSurviveRestart(t *testing.T) {
root := t.TempDir()
service := NewService(root, "device-a")
if err := service.SetBootstrapComplete(true); err != nil {
t.Fatalf("SetBootstrapComplete: %v", err)
}
if err := service.SetLastWarning("file-too-large: archive.bin"); err != nil {
t.Fatalf("SetLastWarning: %v", err)
}
restarted := NewService(root, "")
bootstrapped, err := restarted.BootstrapComplete()
if err != nil {
t.Fatalf("BootstrapComplete: %v", err)
}
if !bootstrapped {
t.Fatal("bootstrap state was lost after restart")
}
warning, err := restarted.LastWarning()
if err != nil {
t.Fatalf("LastWarning: %v", err)
}
if warning != "file-too-large: archive.bin" {
t.Fatalf("warning = %q", warning)
}
}
func TestScanAndRecordSkipsReservedTemporaryAndSymlinkPaths(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".verstak", "sync"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".verstak", "sync", "state.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".verstak-write-local"), []byte("temporary"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "draft.tmp"), []byte("temporary"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "normal.txt"), []byte("normal"), 0o600); err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
if err := os.Symlink(filepath.Join(root, "normal.txt"), filepath.Join(root, "normal-link.txt")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
}
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("baseline: %v", err)
}
snapshot, err := service.LoadSnapshot()
if err != nil {
t.Fatal(err)
}
for _, path := range []string{".verstak/sync/state.json", ".verstak-write-local", "draft.tmp", "normal-link.txt"} {
if _, ok := snapshot.Entries[path]; ok {
t.Fatalf("reserved path %q was included in snapshot %#v", path, snapshot.Entries)
}
}
if _, ok := snapshot.Entries["normal.txt"]; !ok {
t.Fatalf("normal file missing from snapshot %#v", snapshot.Entries)
}
}
func TestScanAndRecordKeepsUnsupportedFileUnresolved(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "too-large.bin")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Truncate(path, maxOperationFileBytes+1); err != nil {
t.Fatal(err)
}
service := NewService(root, "device-a")
warnings, err := service.ScanAndRecord()
if err != nil {
t.Fatalf("scan unsupported file: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "too-large.bin") || !strings.Contains(warnings[0], "file-too-large") {
t.Fatalf("warnings = %v", warnings)
}
assertUnpushedCount(t, service, 0)
snapshot, err := service.LoadSnapshot()
if err != nil {
t.Fatal(err)
}
if _, ok := snapshot.Entries["too-large.bin"]; ok {
t.Fatalf("unsupported file was marked synchronized: %#v", snapshot.Entries)
}
warnings, err = service.ScanAndRecord()
if err != nil || len(warnings) != 1 {
t.Fatalf("second scan warnings=%v err=%v, unresolved file must remain visible", warnings, err)
}
}
func unpushedOps(t *testing.T, service *Service) []Op {
t.Helper()
ops, err := service.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
return ops
}
func assertUnpushedCount(t *testing.T, service *Service, want int) {
t.Helper()
if got := len(unpushedOps(t, service)); got != want {
t.Fatalf("unpushed ops = %d, want %d", got, want)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,12 +7,16 @@ import (
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"github.com/google/uuid"
) )
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) { func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project")) mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test")) mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
mustMkdir(t, filepath.Join(vaultDir, ".verstak")) mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
mustMkdir(t, filepath.Join(vaultDir, ".git")) mustMkdir(t, filepath.Join(vaultDir, ".git"))
mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace") mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace")
@ -34,6 +38,61 @@ func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
} }
} }
func TestListWorkspacesIncludesNestedWorkspaces(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Clients"))
mustMkdir(t, filepath.Join(vaultDir, "Clients", "Romashka"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Clients", "Romashka"))
mustMkdir(t, filepath.Join(vaultDir, "Clients", "Alpha"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Clients", "Alpha"))
mustMkdir(t, filepath.Join(vaultDir, "Personal"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Personal"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 3 {
t.Fatalf("workspaces = %d, want 3", len(workspaces))
}
paths := make([]string, len(workspaces))
for i, ws := range workspaces {
paths[i] = ws.Path
}
wantPaths := []string{"Clients/Alpha", "Clients/Romashka", "Personal"}
if strings.Join(paths, ",") != strings.Join(wantPaths, ",") {
t.Fatalf("paths = %v, want %v", paths, wantPaths)
}
}
func TestCreateWorkspaceNested(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
// Create parent folder first
mustMkdir(t, filepath.Join(vaultDir, "Clients"))
ws, err := m.CreateWorkspace("Clients/Project", "")
if err != nil {
t.Fatalf("CreateWorkspace nested: %v", err)
}
if ws.Path != "Clients/Project" {
t.Fatalf("workspace path = %q, want Clients/Project", ws.Path)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Clients", "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err)
}
// Verify marker
if _, err := os.Stat(filepath.Join(vaultDir, "Clients", "Project", ".verstak", "workspace.json")); err != nil {
t.Fatalf("marker missing: %v", err)
}
}
func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) { func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows") t.Skip("symlink creation needs extra privileges on Windows")
@ -435,6 +494,93 @@ func TestRestoreWorkspaceTrashPreservesIdentity(t *testing.T) {
} }
} }
func TestApplySyncedWorkspaceLifecycleKeepsIdentityAndRejectsNameConflict(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
workspaceID := "5f0f96d9-61c8-4b6b-8c3a-a1b9a0f40001"
meta := Metadata{
WorkspaceID: workspaceID,
WorkspaceName: "Remote",
CreatedFromTemplate: &TemplateSnapshot{
TemplateID: "minimal",
TemplateName: "Minimal",
TemplateVersion: 1,
AppliedAt: "2026-07-17T00:00:00Z",
},
Features: map[string]bool{"files": true},
Folders: map[string]string{"notes": "Notes"},
}
created, err := m.CreateWorkspaceFromSync("Remote", workspaceID, meta)
if err != nil {
t.Fatalf("CreateWorkspaceFromSync: %v", err)
}
if created.ID != workspaceID || created.Name != "Remote" {
t.Fatalf("created = %+v", created)
}
if _, err := m.CreateWorkspaceFromSync("Remote", workspaceID, meta); err != nil {
t.Fatalf("replayed create: %v", err)
}
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote", "Remote-Renamed"); err != nil {
t.Fatalf("RenameWorkspaceFromSync: %v", err)
}
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote", "Remote-Renamed"); err != nil {
t.Fatalf("replayed rename: %v", err)
}
trashed, err := m.TrashWorkspaceFromSync(workspaceID, "Remote-Renamed")
if err != nil {
t.Fatalf("TrashWorkspaceFromSync: %v", err)
}
if trashed.WorkspaceID != workspaceID {
t.Fatalf("trashed = %+v", trashed)
}
if _, err := m.TrashWorkspaceFromSync(workspaceID, "Remote-Renamed"); err != nil {
t.Fatalf("replayed trash: %v", err)
}
restored, err := m.RestoreWorkspaceFromSync(workspaceID, "Remote-Restored")
if err != nil {
t.Fatalf("RestoreWorkspaceFromSync: %v", err)
}
if restored.ID != workspaceID || restored.Name != "Remote-Restored" {
t.Fatalf("restored = %+v", restored)
}
if _, err := m.RestoreWorkspaceFromSync(workspaceID, "Remote-Restored"); err != nil {
t.Fatalf("replayed restore: %v", err)
}
if _, err := m.CreateWorkspace("Taken", "minimal"); err != nil {
t.Fatalf("CreateWorkspace Taken: %v", err)
}
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote-Restored", "Taken"); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("rename conflict error = %v, want conflict", err)
}
if _, err := m.CreateWorkspaceFromSync("Copied", workspaceID, meta); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("duplicate workspace ID error = %v, want conflict", err)
}
stored, err := m.GetWorkspaceMetadata("Remote-Restored")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if stored.WorkspaceID != workspaceID || stored.CreatedFromTemplate == nil || stored.CreatedFromTemplate.TemplateID != "minimal" {
t.Fatalf("stored metadata = %+v", stored)
}
}
func TestCreateWorkspaceFromSyncRejectsUnsafeMetadataFolder(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspaceFromSync("Remote", uuid.NewString(), Metadata{
Folders: map[string]string{"files": "../escaped"},
}); err == nil || !strings.Contains(err.Error(), "invalid workspace folder") {
t.Fatalf("unsafe synced metadata error = %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "escaped")); !os.IsNotExist(err) {
t.Fatalf("unsafe synced metadata created path outside workspace: %v", err)
}
}
func TestPurgeWorkspaceTrashRemovesPayload(t *testing.T) { func TestPurgeWorkspaceTrashRemovesPayload(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
m := NewManager(vaultDir) m := NewManager(vaultDir)
@ -467,23 +613,24 @@ func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
} }
} }
func TestInvalidWorkspaceNamesRejected(t *testing.T) { func TestInvalidWorkspacePathsRejected(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
m := NewManager(vaultDir) m := NewManager(vaultDir)
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"} paths := []string{"", " ", `A\B`, "/abs", `C:\abs`, "..", "a/../b", "bad\x00name", ".verstak", ".Verstak", ".git"}
for _, name := range names { for _, path := range paths {
if _, err := m.CreateWorkspace(name, ""); err == nil { if _, err := m.CreateWorkspace(path, ""); err == nil {
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name) t.Fatalf("CreateWorkspace(%q) succeeded, want invalid path error", path)
} }
} }
} }
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) { func TestCompatibilityTreeIncludesFoldersAndNestedWorkspaces(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project")) mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested")) mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test")) mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir) m := NewManager(vaultDir)
if err := m.Load(); err != nil { if err := m.Load(); err != nil {
@ -493,34 +640,43 @@ func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
if len(tree.Nodes) != 2 { if len(tree.Nodes) != 2 {
t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes) t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes)
} }
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" || tree.Nodes[0].Path != "" { if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" {
t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0]) t.Fatalf("first compatibility node = %+v, want workspace node", tree.Nodes[0])
} }
for _, node := range tree.Nodes { for _, node := range tree.Nodes {
if node.ParentID != "" {
t.Fatalf("compatibility tree should be flat, got child node %+v", node)
}
if node.ID == "Nested" || node.Title == "Nested" { if node.ID == "Nested" || node.Title == "Nested" {
t.Fatalf("nested folders must not become workspace nodes: %+v", tree.Nodes) t.Fatalf("nested folders without markers must not become workspace nodes: %+v", tree.Nodes)
}
}
// Workspace nodes should NOT have ParentID for root-level ones
for _, node := range tree.Nodes {
if node.Type == TypeSpace && node.ID == "Project" && node.ParentID != "" {
t.Fatalf("root workspace should have empty ParentID: %+v", node)
} }
} }
} }
func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) { func TestMoveNodeCreatesNestedWorkspaceModel(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project")) mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test")) mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir) m := NewManager(vaultDir)
if err := m.Load(); err != nil { if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
// Move workspace into Test folder (which is a plain folder without marker)
err := m.MoveNode("Project", "Test") err := m.MoveNode("Project", "Test")
if err == nil || !strings.Contains(err.Error(), "top-level only") { if err != nil {
t.Fatalf("MoveNode error = %v, want top-level only", err) t.Fatalf("MoveNode error = %v, want success", err)
} }
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) { if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); statErr != nil {
t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr) t.Fatalf("MoveNode did not create nested folder, stat err=%v", statErr)
}
// Verify marker moved
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project", ".verstak", "workspace.json")); statErr != nil {
t.Fatalf("marker not in new location: %v", statErr)
} }
} }
@ -547,6 +703,22 @@ func TestMetadataFileShape(t *testing.T) {
} }
} }
func mustWriteWorkspaceMarker(t *testing.T, dir string) {
t.Helper()
markerPath := filepath.Join(dir, ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(markerPath), err)
}
marker := workspaceIdentityMarker{WorkspaceID: uuid.NewString()}
data, err := json.Marshal(marker)
if err != nil {
t.Fatalf("marshal marker: %v", err)
}
if err := os.WriteFile(markerPath, data, 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", markerPath, err)
}
}
func newVaultDir(t *testing.T) string { func newVaultDir(t *testing.T) string {
t.Helper() t.Helper()
vaultDir := filepath.Join(t.TempDir(), "vault") vaultDir := filepath.Join(t.TempDir(), "vault")

View File

@ -2,10 +2,15 @@
package tray package tray
import ( import (
"errors"
"log"
"strings" "strings"
"sync" "sync"
"sync/atomic"
) )
var errBackendUnavailable = errors.New("tray backend is unavailable")
// MenuItem exposes click events from a native tray menu item. // MenuItem exposes click events from a native tray menu item.
type MenuItem interface { type MenuItem interface {
Clicked() <-chan struct{} Clicked() <-chan struct{}
@ -13,13 +18,22 @@ type MenuItem interface {
SetTooltip(tooltip string) SetTooltip(tooltip string)
} }
// BackendCallbacks are invoked by the native tray implementation.
// A backend keeps the platform-native secondary-click handler enabled so the
// operating system opens the menu on a right click.
type BackendCallbacks struct {
Ready func()
Exit func()
LeftClick func()
}
// Backend is the platform tray implementation. // Backend is the platform tray implementation.
type Backend interface { type Backend interface {
Register(onReady func(), onExit func()) Start(callbacks BackendCallbacks) error
SetIcon(icon []byte) SetIcon(icon []byte) error
SetTooltip(tooltip string) SetTooltip(tooltip string) error
AddMenuItem(title, tooltip string) MenuItem AddMenuItem(title, tooltip string) (MenuItem, error)
Quit() Stop()
} }
// Actions are executed from the native tray menu. // Actions are executed from the native tray menu.
@ -80,11 +94,20 @@ func LabelsForPreference(preference string, systemLocales ...string) Labels {
type Controller struct { type Controller struct {
backend Backend backend Backend
icon []byte icon []byte
start sync.Once
startOnce sync.Once
readyOnce sync.Once
stopOnce sync.Once
started atomic.Bool
ready atomic.Bool
mu sync.RWMutex mu sync.RWMutex
labels Labels labels Labels
actions Actions
show MenuItem show MenuItem
quit MenuItem quit MenuItem
startErr error
readyChanged func(bool)
} }
// New creates a tray controller for one application process. // New creates a tray controller for one application process.
@ -96,6 +119,23 @@ func New(backend Backend, icon []byte) *Controller {
} }
} }
// SetReadyChangedHandler receives readiness changes after native initialization
// has either completed or ended. It lets the Wails close policy avoid hiding a
// window before the tray can bring it back.
func (c *Controller) SetReadyChangedHandler(handler func(bool)) {
if c == nil {
return
}
c.mu.Lock()
c.readyChanged = handler
c.mu.Unlock()
}
// Ready reports whether the tray has completed icon and menu initialization.
func (c *Controller) Ready() bool {
return c != nil && c.ready.Load()
}
// SetLabels updates the current and future native tray menu labels. // SetLabels updates the current and future native tray menu labels.
func (c *Controller) SetLabels(labels Labels) { func (c *Controller) SetLabels(labels Labels) {
if c == nil { if c == nil {
@ -108,34 +148,141 @@ func (c *Controller) SetLabels(labels Labels) {
applyLabels(show, quit, labels) applyLabels(show, quit, labels)
} }
// Start registers the native tray without taking over the Wails event loop. // Start connects the tray to Wails without taking over Wails' GUI event loop.
func (c *Controller) Start(actions Actions) { // Ready remains false until the backend callback and all required menu setup
// finish successfully.
func (c *Controller) Start(actions Actions) error {
if c == nil || c.backend == nil { if c == nil || c.backend == nil {
return errBackendUnavailable
}
c.startOnce.Do(func() {
c.mu.Lock()
c.actions = actions
c.mu.Unlock()
c.started.Store(true)
log.Printf("[tray] starting native backend")
err := c.backend.Start(BackendCallbacks{
Ready: c.onReady,
Exit: c.onExit,
LeftClick: c.onLeftClick,
})
if err != nil {
c.mu.Lock()
c.startErr = err
c.mu.Unlock()
c.started.Store(false)
c.setReady(false)
log.Printf("[tray] backend start failed: %v; falling back to normal window close", err)
}
})
c.mu.RLock()
err := c.startErr
c.mu.RUnlock()
return err
}
func (c *Controller) onReady() {
if c == nil {
return
}
if !c.started.Load() {
log.Printf("[tray] ready callback ignored after backend startup failed")
return
}
c.readyOnce.Do(func() {
log.Printf("[tray] native backend reported ready")
if err := c.backend.SetIcon(c.icon); err != nil {
c.fail("icon setup", err)
return
}
if err := c.backend.SetTooltip("Verstak"); err != nil {
c.fail("tooltip setup", err)
return return
} }
c.start.Do(func() {
c.backend.Register(func() {
c.backend.SetIcon(c.icon)
c.backend.SetTooltip("Verstak")
c.mu.RLock() c.mu.RLock()
labels := c.labels labels := c.labels
actions := c.actions
c.mu.RUnlock() c.mu.RUnlock()
show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip) show, err := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip) if err != nil || show == nil {
if err == nil {
err = errors.New("show menu item is nil")
}
c.fail("show menu creation", err)
return
}
quit, err := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
if err != nil || quit == nil {
if err == nil {
err = errors.New("quit menu item is nil")
}
c.fail("quit menu creation", err)
return
}
c.mu.Lock() c.mu.Lock()
c.show, c.quit = show, quit c.show, c.quit = show, quit
labels = c.labels labels = c.labels
c.mu.Unlock() c.mu.Unlock()
applyLabels(show, quit, labels) applyLabels(show, quit, labels)
if actions.Show != nil && show != nil { if actions.Show != nil {
go routeClicks(show.Clicked(), actions.Show) go routeClicks(show.Clicked(), func() {
} log.Printf("[tray] Show command")
if actions.Quit != nil && quit != nil { actions.Show()
go routeClicks(quit.Clicked(), actions.Quit)
}
}, nil)
}) })
} }
if actions.Quit != nil {
go routeClicks(quit.Clicked(), func() {
log.Printf("[tray] Quit command")
actions.Quit()
})
}
c.setReady(true)
log.Printf("[tray] native tray is ready")
})
}
func (c *Controller) onLeftClick() {
if c == nil {
return
}
log.Printf("[tray] left click")
if !c.Ready() {
log.Printf("[tray] left click ignored while tray is not ready")
return
}
c.mu.RLock()
show := c.actions.Show
c.mu.RUnlock()
if show != nil {
show()
}
}
func (c *Controller) onExit() {
if c == nil {
return
}
c.setReady(false)
log.Printf("[tray] native message loop ended; falling back to normal window close")
}
func (c *Controller) fail(stage string, err error) {
c.setReady(false)
log.Printf("[tray] %s failed: %v; falling back to normal window close", stage, err)
c.Stop()
}
func (c *Controller) setReady(ready bool) {
if c == nil || c.ready.Swap(ready) == ready {
return
}
c.mu.RLock()
handler := c.readyChanged
c.mu.RUnlock()
if handler != nil {
handler(ready)
}
}
func applyLabels(show, quit MenuItem, labels Labels) { func applyLabels(show, quit MenuItem, labels Labels) {
if show != nil { if show != nil {
@ -150,10 +297,14 @@ func applyLabels(show, quit MenuItem, labels Labels) {
// Stop releases the native tray after Wails has begun application shutdown. // Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() { func (c *Controller) Stop() {
if c == nil || c.backend == nil { if c == nil || c.backend == nil || !c.started.Load() {
return return
} }
c.backend.Quit() c.stopOnce.Do(func() {
c.setReady(false)
log.Printf("[tray] stopping native backend")
c.backend.Stop()
})
} }
func routeClicks(clicked <-chan struct{}, action func()) { func routeClicks(clicked <-chan struct{}, action func()) {

View File

@ -1,6 +1,8 @@
package tray package tray
import ( import (
"errors"
"sync"
"testing" "testing"
"time" "time"
) )
@ -11,47 +13,65 @@ type fakeMenuItem struct {
tooltip string tooltip string
} }
func (i *fakeMenuItem) Clicked() <-chan struct{} { func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked }
return i.clicked func (i *fakeMenuItem) SetTitle(title string) { i.title = title }
} func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip }
func (i *fakeMenuItem) SetTitle(title string) {
i.title = title
}
func (i *fakeMenuItem) SetTooltip(tooltip string) {
i.tooltip = tooltip
}
type fakeBackend struct { type fakeBackend struct {
callbacks BackendCallbacks
icon []byte icon []byte
tooltip string tooltip string
items map[string]*fakeMenuItem items map[string]*fakeMenuItem
quitCalls int startErr error
registering bool iconErr error
menuErr error
starts int
stops int
} }
func (b *fakeBackend) Register(onReady func(), _ func()) { func (b *fakeBackend) Start(callbacks BackendCallbacks) error {
b.registering = true b.starts++
onReady() b.callbacks = callbacks
return b.startErr
} }
func (b *fakeBackend) SetIcon(icon []byte) { func (b *fakeBackend) SetIcon(icon []byte) error {
b.icon = append([]byte(nil), icon...) b.icon = append([]byte(nil), icon...)
return b.iconErr
} }
func (b *fakeBackend) SetTooltip(tooltip string) { func (b *fakeBackend) SetTooltip(tooltip string) error {
b.tooltip = tooltip b.tooltip = tooltip
return nil
} }
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem { func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title} if b.menuErr != nil {
return nil, b.menuErr
}
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip}
b.items[title] = item b.items[title] = item
return item return item, nil
} }
func (b *fakeBackend) Quit() { func (b *fakeBackend) Stop() { b.stops++ }
b.quitCalls++
func (b *fakeBackend) ready() {
if b.callbacks.Ready != nil {
b.callbacks.Ready()
}
}
func (b *fakeBackend) exited() {
if b.callbacks.Exit != nil {
b.callbacks.Exit()
}
}
func (b *fakeBackend) leftClick() {
if b.callbacks.LeftClick != nil {
b.callbacks.LeftClick()
}
} }
func waitFor(t *testing.T, signal <-chan struct{}) { func waitFor(t *testing.T, signal <-chan struct{}) {
@ -63,64 +83,112 @@ func waitFor(t *testing.T, signal <-chan struct{}) {
} }
} }
func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) { func TestControllerBecomesReadyOnlyAfterBackendCallback(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 1)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1, 2, 3}) controller := New(backend, []byte{1, 2, 3})
controller.Start(Actions{ if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
if controller.Ready() {
t.Fatal("tray became ready before backend callback")
}
backend.ready()
if !controller.Ready() {
t.Fatal("tray did not become ready after backend callback")
}
if string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = icon:%v tooltip:%q", backend.icon, backend.tooltip)
}
}
func TestControllerRoutesLeftClickAndMenuActionsToShowAndQuit(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 2)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1})
if err := controller.Start(Actions{
Show: func() { showCalls <- struct{}{} }, Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} }, Quit: func() { quitCalls <- struct{}{} },
}) }); err != nil {
t.Fatalf("Start() error = %v", err)
if !backend.registering || string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = registering:%t icon:%v tooltip:%q", backend.registering, backend.icon, backend.tooltip)
} }
backend.ready()
backend.leftClick()
waitFor(t, showCalls)
showItem := backend.items["Show Verstak"] showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"] quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil { if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items) t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
} }
showItem.clicked <- struct{}{} showItem.clicked <- struct{}{}
waitFor(t, showCalls) waitFor(t, showCalls)
quitItem.clicked <- struct{}{} quitItem.clicked <- struct{}{}
waitFor(t, quitCalls) waitFor(t, quitCalls)
} }
func TestControllerStopsNativeTrayBackend(t *testing.T) { func TestControllerStopCallsBackendOnce(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1}) controller := New(backend, []byte{1})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
controller.Stop()
controller.Stop() controller.Stop()
if backend.quitCalls != 1 { if backend.stops != 1 {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls) t.Fatalf("backend stop calls = %d, want 1", backend.stops)
} }
} }
func TestControllerUsesAndUpdatesLocalizedLabels(t *testing.T) { func TestControllerStartupAndSetupFailuresNeverBecomeReady(t *testing.T) {
for name, backend := range map[string]*fakeBackend{
"start": {items: make(map[string]*fakeMenuItem), startErr: errors.New("start failed")},
"icon": {items: make(map[string]*fakeMenuItem), iconErr: errors.New("icon failed")},
"menu": {items: make(map[string]*fakeMenuItem), menuErr: errors.New("menu failed")},
} {
t.Run(name, func(t *testing.T) {
controller := New(backend, []byte{1})
err := controller.Start(Actions{})
if name == "start" && err == nil {
t.Fatal("Start() error = nil, want startup failure")
}
if name != "start" && err != nil {
t.Fatalf("Start() error = %v, want nil before ready callback", err)
}
backend.ready()
if controller.Ready() {
t.Fatal("failed tray became ready")
}
})
}
}
func TestControllerExitRevokesReadinessAndKeepsLocalizedMenuItems(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1}) controller := New(backend, []byte{1})
controller.SetLabels(LabelsForPreference("ru")) controller.SetLabels(LabelsForPreference("ru"))
controller.Start(Actions{}) if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
show := backend.items["Показать Верстак"] show := backend.items["Показать Верстак"]
quit := backend.items["Выйти"] quit := backend.items["Выйти"]
if show == nil || quit == nil { if show == nil || quit == nil {
t.Fatalf("Russian tray menu = %#v", backend.items) t.Fatalf("Russian tray menu = %#v", backend.items)
} }
if show.tooltip != "Показать окно Верстака" || quit.tooltip != "Завершить Верстак" {
t.Fatalf("Russian tray tooltips = show:%q quit:%q", show.tooltip, quit.tooltip)
}
controller.SetLabels(LabelsForPreference("en")) controller.SetLabels(LabelsForPreference("en"))
if show.title != "Show Verstak" || quit.title != "Quit" { if show.title != "Show Verstak" || quit.title != "Quit" {
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title) t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title)
} }
if show.tooltip != "Show the Verstak window" || quit.tooltip != "Quit Verstak" { backend.exited()
t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip) if controller.Ready() {
t.Fatal("tray stayed ready after backend exit")
} }
} }
@ -134,3 +202,26 @@ func TestLabelsForSystemRussianLocale(t *testing.T) {
t.Fatalf("higher-priority system locale must win, got %#v", labels) t.Fatalf("higher-priority system locale must win, got %#v", labels)
} }
} }
func TestControllerReadyNotificationIsSafeAcrossCallbacks(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
var changes []bool
var mu sync.Mutex
controller.SetReadyChangedHandler(func(ready bool) {
mu.Lock()
changes = append(changes, ready)
mu.Unlock()
})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
backend.exited()
mu.Lock()
defer mu.Unlock()
if len(changes) != 2 || !changes[0] || changes[1] {
t.Fatalf("ready changes = %#v, want [true false]", changes)
}
}

View File

@ -1,14 +0,0 @@
package tray
import "encoding/base64"
const iconPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACT1BMVEUAAACOjo7///8RERHY2NghISEDAwOjo6MODg7GxsYQEBDIyMgPDw/Hx8cKCgq9vb12dnaHh4fFxcV1dXWjo6OoqKinp6eNjY25ubnr6+v29vb29vb29vbx8fHV1dXm5ub9/f3z8/OUlJQAAADu7u74+Pi5ubkAAADu7u74+Pi6urq6urrs7Oz39/ezs7PZ2dn5+fn8/Pzq6uobGxtdXV3U1NTl5eXl5eXl5eXm5ubd3d2urq7////+/v7//v7//v/s7OzLy8vLy8ru7u708/POzc3Kysrd3d39/f3Z2dnU1NT7+/u0tLQpKSkoKCcnJycsLCy+vr63t7crKyskJCRzc3NQUFAlJSUoKChvb28gICAdHR0cHBzDwsJ6enoaGhkZGRltbW3MzMwqKioeHh6pqam+vb0iIiJGRkYbGxseHR0aGhpubm6cnJw6Ojrg4ODExMQmJiYeHh36+vppaWhxcXH8/PwvLy/a2tmKiorl5eU+Pj6zs7PQ0NA1NTXR0dFSUlIbGhq/v79BQUHn5+fW1tYuLi48PDyysrItLS02NjaNjY17e3v///7b29syMjJ/f38hISEqKSlqampaWlq9vb3f398cGxs9PT1KSko3NzeioqJRUVFJSUnk5OQaGRljY2OGhobp6ek/Pz+dnZ3IyMgoKSjHx8ZDQ0PHx8fx8fHw8PBVVVX39/cdHh2RkZHz8/NPT0+Ojo7Dw8PPz8/29vZUVFTCwsL19fX4+PhYWFgWFhYXFxfAwMCqqqqFhYWHhoaEhITe3t6MjIzi4uIwLXXJAAAAPHRSTlMAAAAAAAAAAAAAAAAAAAAAAAAABRIWFQoUis3R0K82b/22DQGZ2x0Cm9wfHo/UGULi+IIEAzZudHNzUA0lLC8OAAAAAWJLR0QCZgt8ZAAAAAd0SU1FB+oHDRIyN3EuEOkAAAHzSURBVDjLvdPXXxNBEAdwPHvvFewde8WyG3NGT41KlFxMwoyKguLeCCZRUCzRgIXYUexEjR17713/MPfCB+Vy8ZXfw+3tzvdlbueyslolbfr26z8gQwYqbVP1doMGD8nOsSU7Z+gwpb2sd1CGj2CZM3JURwk6jR4j37l5wDlvejrkssjBxo7rLEGX8RPk1qmqi1UXW7JU05bx5Ss0VXOvZBNzu/4Fq1bne9asZQVej+7jrnX+gB6UYNI/UFAIqK9nGzZC0Sa2uRihZAuzgK2lAB7VtU0YtJ2VIWD5DisI+QG87lAYIhTcuYsiVFFpBVo+4O49VQKAKvbuE1C6XzbWEhwoBowePBSDEvJV+0HUHE4DR44iHquN43G9yHdCROgkP2UFp8+gqDkLdcFzdL6ewH9Bfi8LuBjFS5ev0NVrAdEQRZFwpoPr5QiB8I2b1WHw1oFIMkcauHVbGAB4565sF+De/dS9tATOB8IwMO5uNEGs4aENPEpIQI+fhHR4CvjMrFvB8xcxA8RL9kqHCMZf28Gbt2RAoJG9k4Dq39vBh4+f6HPCxb4UElFZqm4FlV+/ff9Ry9nPqmTy128L6GYCR/OwNY9eE8jtLkEPZTL/z9BOmdrTHHtl2nSeMTNmzuplgt6z58zNm2dL3vwFC/u0yo/7B9C02RGfGBOeAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA3LTEzVDE0OjIyOjMzKzAwOjAwJHU36gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNy0xM1QxNDoyMjozMyswMDowMFUoj1YAAAAASUVORK5CYII="
// DefaultIcon returns the compact source-controlled Verstak tray icon.
func DefaultIcon() []byte {
icon, err := base64.StdEncoding.DecodeString(iconPNGBase64)
if err != nil {
return nil
}
return icon
}

View File

@ -0,0 +1,16 @@
//go:build linux
package tray
import _ "embed"
//go:embed verstak.png
var defaultIcon []byte
// DefaultIcon returns the Linux PNG tray icon embedded in the binary.
func DefaultIcon() []byte {
return append([]byte(nil), defaultIcon...)
}
// IconFileExtension is used when the backend materializes the embedded icon.
func IconFileExtension() string { return ".png" }

View File

@ -0,0 +1,43 @@
//go:build linux
package tray
import (
"bytes"
"image"
"image/color"
_ "image/png"
"testing"
)
func TestDefaultIconUsesPlatformResource(t *testing.T) {
icon := DefaultIcon()
if len(icon) == 0 {
t.Fatal("DefaultIcon() returned no data")
}
if IconFileExtension() != ".png" {
t.Fatalf("IconFileExtension() = %q, want .png on Linux", IconFileExtension())
}
if string(icon[:8]) != "\x89PNG\r\n\x1a\n" {
t.Fatal("DefaultIcon() is not PNG data on Linux")
}
assertVerstakBrand(t, icon)
}
func assertVerstakBrand(t *testing.T, icon []byte) {
t.Helper()
image, _, err := image.Decode(bytes.NewReader(icon))
if err != nil {
t.Fatalf("decode tray icon: %v", err)
}
want := color.NRGBA{R: 0x1c, G: 0x2f, B: 0x4a, A: 0xff}
for y := image.Bounds().Min.Y; y < image.Bounds().Max.Y; y++ {
for x := image.Bounds().Min.X; x < image.Bounds().Max.X; x++ {
if color.NRGBAModel.Convert(image.At(x, y)) == want {
return
}
}
}
t.Fatal("tray icon does not contain the Verstak navy brand color")
}

View File

@ -0,0 +1,16 @@
//go:build windows
package tray
import _ "embed"
//go:embed verstak.ico
var defaultIcon []byte
// DefaultIcon returns the multi-resolution Windows ICO embedded in the binary.
func DefaultIcon() []byte {
return append([]byte(nil), defaultIcon...)
}
// IconFileExtension is used when the backend materializes the embedded icon.
func IconFileExtension() string { return ".ico" }

View File

@ -0,0 +1,37 @@
//go:build windows
package tray
import (
"encoding/binary"
"testing"
)
func TestDefaultIconIsMultiResolutionICO(t *testing.T) {
icon := DefaultIcon()
if IconFileExtension() != ".ico" || len(icon) < 6 {
t.Fatalf("Windows tray icon is not ICO data")
}
if binary.LittleEndian.Uint16(icon[0:2]) != 0 || binary.LittleEndian.Uint16(icon[2:4]) != 1 {
t.Fatal("Windows tray icon has an invalid ICO header")
}
count := int(binary.LittleEndian.Uint16(icon[4:6]))
if count < 6 {
t.Fatalf("ICO image count = %d, want at least 6", count)
}
want := map[int]bool{16: false, 20: false, 24: false, 32: false, 48: false, 256: false}
for offset := 6; offset+16 <= len(icon) && offset < 6+count*16; offset += 16 {
size := int(icon[offset])
if size == 0 {
size = 256
}
if _, ok := want[size]; ok {
want[size] = true
}
}
for size, found := range want {
if !found {
t.Errorf("ICO is missing %dx%d image", size, size)
}
}
}

View File

@ -1,36 +1,141 @@
package tray package tray
import "github.com/getlantern/systray" import (
"errors"
"fmt"
"os"
"strings"
"sync"
type systrayBackend struct{} "fyne.io/systray"
)
var (
runWithExternalLoop = systray.RunWithExternalLoop
setOnTapped = systray.SetOnTapped
)
type systrayBackend struct {
mu sync.Mutex
end func()
iconPath string
started bool
stopRequested bool
}
type systrayMenuItem struct { type systrayMenuItem struct {
item *systray.MenuItem item *systray.MenuItem
} }
// NewNativeBackend creates the cross-platform native tray backend. // NewNativeBackend creates the cross-platform tray backend. It uses
// RunWithExternalLoop so the native tray message loop runs alongside Wails.
func NewNativeBackend() Backend { func NewNativeBackend() Backend {
return systrayBackend{} return &systrayBackend{}
} }
func (systrayBackend) Register(onReady func(), onExit func()) { func (b *systrayBackend) Start(callbacks BackendCallbacks) (err error) {
systray.Register(onReady, onExit) if b == nil {
return errBackendUnavailable
}
b.mu.Lock()
if b.started {
b.mu.Unlock()
return errors.New("native tray backend was already started")
}
b.started = true
b.mu.Unlock()
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("native tray startup panic: %v", recovered)
}
}()
start, end := runWithExternalLoop(callbacks.Ready, callbacks.Exit)
if callbacks.LeftClick != nil {
setOnTapped(callbacks.LeftClick)
}
// The Ready callback is asynchronous on Windows and can synchronously
// decide that startup failed. Start the native loop before publishing the
// end function so a concurrent Stop is never left with a half-started loop.
start()
b.mu.Lock()
stopRequested := b.stopRequested
if !stopRequested {
b.end = end
}
b.mu.Unlock()
if stopRequested {
end()
}
return nil
} }
func (systrayBackend) SetIcon(icon []byte) { func (b *systrayBackend) SetIcon(icon []byte) error {
systray.SetIcon(icon) if b == nil || len(icon) == 0 {
return errors.New("tray icon is empty")
}
file, err := os.CreateTemp("", "verstak-tray-*"+IconFileExtension())
if err != nil {
return fmt.Errorf("create tray icon file: %w", err)
}
path := file.Name()
if _, err := file.Write(icon); err != nil {
file.Close()
os.Remove(path)
return fmt.Errorf("write tray icon file: %w", err)
}
if err := file.Close(); err != nil {
os.Remove(path)
return fmt.Errorf("close tray icon file: %w", err)
}
if err := systray.SetIconFromFilePath(path); err != nil {
os.Remove(path)
return fmt.Errorf("set native tray icon: %w", err)
}
b.mu.Lock()
previous := b.iconPath
b.iconPath = path
b.mu.Unlock()
if previous != "" && previous != path {
_ = os.Remove(previous)
}
return nil
} }
func (systrayBackend) SetTooltip(tooltip string) { func (b *systrayBackend) SetTooltip(tooltip string) error {
if strings.TrimSpace(tooltip) == "" {
return errors.New("tray tooltip is empty")
}
systray.SetTooltip(tooltip) systray.SetTooltip(tooltip)
return nil
} }
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem { func (b *systrayBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)} if strings.TrimSpace(title) == "" {
return nil, errors.New("tray menu title is empty")
}
item := systray.AddMenuItem(title, tooltip)
if item == nil {
return nil, errors.New("native tray menu item is nil")
}
return systrayMenuItem{item: item}, nil
} }
func (systrayBackend) Quit() { func (b *systrayBackend) Stop() {
systray.Quit() if b == nil {
return
}
b.mu.Lock()
end, iconPath := b.end, b.iconPath
b.end = nil
b.iconPath = ""
b.stopRequested = true
b.mu.Unlock()
if end != nil {
end()
}
if iconPath != "" {
_ = os.Remove(iconPath)
}
} }
func (item systrayMenuItem) Clicked() <-chan struct{} { func (item systrayMenuItem) Clicked() <-chan struct{} {

View File

@ -0,0 +1,30 @@
package tray
import "testing"
func TestBackendStopsWhenReadyCallbackStopsDuringStartup(t *testing.T) {
originalRun := runWithExternalLoop
originalSetOnTapped := setOnTapped
defer func() {
runWithExternalLoop = originalRun
setOnTapped = originalSetOnTapped
}()
var startCalls, endCalls int
runWithExternalLoop = func(onReady, _ func()) (func(), func()) {
onReady()
return func() { startCalls++ }, func() { endCalls++ }
}
setOnTapped = func(func()) {}
backend := &systrayBackend{}
if err := backend.Start(BackendCallbacks{Ready: backend.Stop}); err != nil {
t.Fatalf("Start: %v", err)
}
if startCalls != 1 {
t.Fatalf("start calls = %d, want 1", startCalls)
}
if endCalls != 1 {
t.Fatalf("end calls = %d, want 1", endCalls)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

15
main.go
View File

@ -99,13 +99,9 @@ func main() {
} }
log.Printf("[main] registered vault capability") log.Printf("[main] registered vault capability")
// Register workspace capability (only when vault is open and workspace initialized) // Workspace capability is registered as a platform capability at startup;
if workspaceMgr != nil && workspaceMgr.IsInitialized() { // explicit registration here is a no-op guard for late initialisation.
if err := capRegistry.Register(corePluginID, []string{"verstak/core/workspace/v1"}); err != nil { _ = capRegistry.Register(corePluginID, []string{"verstak/core/workspace/v1"})
log.Fatalf("[main] failed to register workspace capability: %v", err)
}
log.Printf("[main] registered workspace capability")
}
// ─── Plugin Discovery ─────────────────────────────────── // ─── Plugin Discovery ───────────────────────────────────
discoveryDirs := plugin.DefaultDiscoveryDirs() discoveryDirs := plugin.DefaultDiscoveryDirs()
@ -230,6 +226,7 @@ func main() {
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled)
app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now)) app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon()) trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
trayController.SetReadyChangedHandler(app.SetTrayReady)
trayLabels := func(language string) tray.Labels { trayLabels := func(language string) tray.Labels {
return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG")) return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG"))
} }
@ -258,7 +255,9 @@ func main() {
OnStartup: app.Startup, OnStartup: app.Startup,
OnDomReady: func(ctx context.Context) { OnDomReady: func(ctx context.Context) {
app.DomReady(ctx) app.DomReady(ctx)
trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit}) if err := trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit}); err != nil {
log.Printf("[tray] disabled: %v; normal window close will exit", err)
}
}, },
OnShutdown: func(ctx context.Context) { OnShutdown: func(ctx context.Context) {
trayController.Stop() trayController.Stop()

View File

@ -3,7 +3,7 @@ Version: @VERSION@
Section: utils Section: utils
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0, libayatana-appindicator3-1 Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0
Maintainer: Verstak contributors <dev@verstak.app> Maintainer: Verstak contributors <dev@verstak.app>
Homepage: https://github.com/mirivlad/verstak Homepage: https://github.com/mirivlad/verstak
Description: Local-first workspace Description: Local-first workspace

View File

@ -0,0 +1,7 @@
## Highlights
- First public alpha of the local Verstak workbench.
- Introduces Deals, workspace templates, the Overview, Files, Notes, Journal, activity tracking, browser intake, and the official plugin system.
- Includes portable Windows, Debian, and AppImage packages for early testing.
**Commit history**: https://github.com/mirivlad/verstak/commits/v0.1.0-alpha.1

View File

@ -0,0 +1,7 @@
## Highlights
- Adds scheduled native notifications and Todo reminders.
- Keeps Verstak available in the system tray, including the required Linux tray runtime in packages.
- Improves the public alpha installation and troubleshooting documentation.
**Full Changelog**: https://github.com/mirivlad/verstak/compare/v0.1.0-alpha.1...v0.1.0-alpha.2

View File

@ -0,0 +1,7 @@
## Highlights
- Replaces technical workspace terminology in the main interface with the user-facing term “Deal”.
- Improves Russian localization, operation errors, template guidance, and the plugin manager filters.
- Makes global Journal and Secrets flows usable and improves Todo reminder time selection.
**Full Changelog**: https://github.com/mirivlad/verstak/compare/v0.1.0-alpha.2...v0.1.0-alpha.3

View File

@ -0,0 +1,9 @@
## Highlights
- The Deal rename dialog now uses the selected application language.
- Native tray actions use the selected language and refresh when it changes.
- The bundled official plugins include clearer, localized file and folder labels.
## Packages
This prerelease provides a portable Windows archive, a Debian package, and an AppImage for Linux.

View File

@ -0,0 +1,31 @@
## Highlights
- Fixed the Windows 10 system-tray integration: the release now embeds a real
multi-resolution Windows icon and runs the native tray message loop.
- One left click on the tray icon restores and focuses Verstak. A right click
opens the native, localized **Show Verstak** and **Quit** menu.
- Closing the window now hides it only after the tray is confirmed ready. If
the tray cannot start, the app exits normally instead of becoming unreachable.
- Replaced the obsolete tray dependency with `fyne.io/systray`, removing the
AppIndicator requirement from Linux builds, Debian packages, and AppImages.
## Главное
- Исправлена интеграция с системным треем в Windows 10: релиз содержит
настоящий многомасштабный значок Windows и запускает нативный цикл сообщений
трея.
- Один левый клик по значку возвращает и фокусирует Верстак. Правый клик
открывает нативное локализованное меню **«Показать Верстак»** и **«Выйти»**.
- Окно скрывается только после подтверждённой готовности трея. Если трей не
запускается, приложение завершается обычным образом и не остаётся
недоступным.
- Устаревшая зависимость трея заменена на `fyne.io/systray`; AppIndicator
больше не нужен для Linux-сборки, Debian-пакета и AppImage.
## Packages / Пакеты
This prerelease provides a portable Windows archive, a Debian package, and an
AppImage for Linux.
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
для Linux.

View File

@ -0,0 +1,27 @@
## Highlights
- Replaced the generic Wails image in the Linux tray and Windows application
resources with the current Verstak workbench icon.
- The desktop build now generates its PNG and multi-resolution ICO resources
from the committed Verstak SVG before packaging, preventing a generic icon
from returning in a clean build.
- The Windows ICO contains 16, 20, 24, 32, 48, and 256 pixel variants; Linux
uses the matching transparent PNG tray image.
## Главное
- Стандартный знак Wails в Linux-трее и ресурсах Windows заменён на актуальный
фирменный значок Верстака.
- Перед упаковкой desktop-сборка создаёт PNG и многомасштабный ICO из SVG,
хранящегося в репозитории. Это не позволяет стандартному значку вернуться в
чистой сборке.
- Windows ICO содержит варианты 16, 20, 24, 32, 48 и 256 пикселей; Linux
использует соответствующий прозрачный PNG для трея.
## Packages / Пакеты
This prerelease provides a portable Windows archive, a Debian package, and an
AppImage for Linux.
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
для Linux.

View File

@ -0,0 +1,22 @@
## Highlights
- Made branded tray and application icon generation deterministic: repeated
local builds no longer leave timestamp-only changes in the working tree.
- The packaging checks now verify that all PNG and ICO icon outputs are stable
across consecutive generations.
## Главное
- Генерация фирменных значков трея и приложения стала детерминированной:
повторная локальная сборка больше не оставляет в рабочем дереве изменения,
отличающиеся только временной меткой.
- Проверки упаковки теперь убеждаются, что PNG и ICO остаются одинаковыми при
последовательных генерациях.
## Packages / Пакеты
This prerelease provides a portable Windows archive, a Debian package, and an
AppImage for Linux.
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
для Linux.

View File

@ -0,0 +1,39 @@
## Highlights
- **Nested workspaces.** Deals can now live at any depth inside plain vault
folders. Identity stays with the `.verstak/workspace.json` UUID marker;
the path is just an address.
- **Folder metadata.** Plain vault folders can carry an icon, color, and
display order through `.verstak/folder-metadata/`.
- **Plugin-replaceable Deal tree.** A new `workspaceTree` contribution point
lets plugins replace the sidebar Deal tree entirely. The included
`verstak.workspace-folders` official plugin adds folder icons, color,
draganddrop, a full IconPicker (1703 Lucide icons), and a color picker.
- **Move workspaces.** `MoveWorkspace` moves a Deal between plain folders.
- **Plugin API:** `api.workspaces.*` and `api.folders.*` are now available to
bundled frontend plugins.
- Documentation was pruned and aligned with the current implementation state.
## Главное
- **Вложенные Дела.** Дела теперь можно размещать на любой глубине внутри
обычных папок vault. Идентичность хранится в UUID-маркере
`.verstak/workspace.json`; путь — это только адрес.
- **Метаданные папок.** Обычные папки vault могут иметь иконку, цвет и порядок
отображения через `.verstak/folder-metadata/`.
- **Заменяемое дерево Дел.** Новый contribution point `workspaceTree` позволяет
плагинам полностью заменить дерево Дел в сайдбаре. Включённый официальный
плагин `verstak.workspace-folders` добавляет иконки папок, цвета,
draganddrop, полный IconPicker (1703 иконки Lucide) и выбор цвета.
- **Перемещение Дел.** `MoveWorkspace` перемещает Дело между обычными папками.
- **Plugin API:** методы `api.workspaces.*` и `api.folders.*` доступны
bundled frontend-плагинам.
- Документация почищена и приведена в соответствие с текущим состоянием.
## Packages / Пакеты
This prerelease provides a portable Windows archive, a Debian package, and an
AppImage for Linux.
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
для Linux.

View File

@ -35,6 +35,8 @@ fi
cd "$ROOT" cd "$ROOT"
echo "=== verstak desktop Windows amd64 build ===" echo "=== verstak desktop Windows amd64 build ==="
"$ROOT/scripts/generate-brand-icons.sh"
if [[ ! -d "$ROOT/frontend/node_modules" ]]; then if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
if [[ -f "$ROOT/frontend/package-lock.json" ]]; then if [[ -f "$ROOT/frontend/package-lock.json" ]]; then
(cd "$ROOT/frontend" && npm ci --no-audit --no-fund) (cd "$ROOT/frontend" && npm ci --no-audit --no-fund)

View File

@ -6,6 +6,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
echo "=== verstak-desktop build ===" echo "=== verstak-desktop build ==="
echo "" echo ""
"$ROOT/scripts/generate-brand-icons.sh"
# ── Dependency checks ── # ── Dependency checks ──
echo "[deps]" echo "[deps]"
if ! command -v go &>/dev/null; then if ! command -v go &>/dev/null; then
@ -26,15 +28,6 @@ if ! command -v npm &>/dev/null; then
fi fi
echo " ✅ npm $(npm --version)" echo " ✅ npm $(npm --version)"
if [[ "$(go env GOOS)" == "linux" ]]; then
if ! command -v pkg-config &>/dev/null || ! pkg-config --exists ayatana-appindicator3-0.1; then
echo " ❌ native tray development files are missing"
echo " Debian/Ubuntu: sudo apt install libayatana-appindicator3-dev"
exit 1
fi
echo " ✅ ayatana-appindicator3 $(pkg-config --modversion ayatana-appindicator3-0.1)"
fi
# ── Frontend (build first — Go //go:embed needs frontend/dist/) ── # ── Frontend (build first — Go //go:embed needs frontend/dist/) ──
echo "" echo ""
echo "[frontend]" echo "[frontend]"

48
scripts/generate-brand-icons.sh Executable file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MAGICK="${MAGICK_BIN:-magick}"
SOURCE="$ROOT/packaging/linux/verstak.svg"
if ! command -v "$MAGICK" >/dev/null; then
echo "ImageMagick is required to generate Verstak application icons: $MAGICK not found" >&2
exit 1
fi
if [[ ! -f "$SOURCE" ]]; then
echo "Verstak SVG icon source is missing: $SOURCE" >&2
exit 1
fi
render_png() {
local size="$1"
local target="$2"
mkdir -p "$(dirname "$target")"
"$MAGICK" -background none "$SOURCE" -resize "${size}x${size}" -strip "PNG32:$target"
}
render_ico() {
local target="$1"
shift
local temporary
temporary="$(mktemp -d)"
local images=()
for size in "$@"; do
local image="$temporary/${size}.png"
render_png "$size" "$image"
images+=("$image")
done
mkdir -p "$(dirname "$target")"
"$MAGICK" "${images[@]}" "$target"
rm -rf "$temporary"
}
render_png 256 "$ROOT/internal/shell/tray/verstak.png"
render_ico "$ROOT/internal/shell/tray/verstak.ico" 16 20 24 32 48 256
# Wails consumes this PNG while it prepares the Windows executable resources.
# Both files are generated build inputs and therefore deliberately ignored by Git.
render_png 1024 "$ROOT/build/appicon.png"
render_ico "$ROOT/build/windows/icon.ico" 16 32 48 64 128 256
echo "generated Verstak tray and application icons from $SOURCE"

View File

@ -106,11 +106,6 @@ while IFS= read -r candidate; do
done < "$APPDIR/.elf-queue" done < "$APPDIR/.elf-queue"
rm -f "$APPDIR/.elf-queue" rm -f "$APPDIR/.elf-queue"
if ! compgen -G "$APPDIR/usr/lib/libayatana-appindicator3.so.*" >/dev/null; then
echo "native tray runtime library was not bundled" >&2
exit 1
fi
if command -v glib-compile-schemas >/dev/null; then if command -v glib-compile-schemas >/dev/null; then
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas" glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas"
fi fi

View File

@ -6,6 +6,7 @@ VERSION="${1:-}"
REPOSITORY="mirivlad/verstak" REPOSITORY="mirivlad/verstak"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}" RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}" RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
GIT_BIN="${GIT_BIN:-git}" GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}" GH_BIN="${GH_BIN:-gh}"
@ -65,7 +66,17 @@ fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber "$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else else
RELEASE_OPTIONS=(--generate-notes --verify-tag) NOTES_FILE="$RELEASE_NOTES_DIR/$VERSION.md"
if [[ ! -s "$NOTES_FILE" ]]; then
echo "human-readable release notes are required: $NOTES_FILE" >&2
exit 1
fi
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --verify-tag)
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
fi
if [[ "$VERSION" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease) RELEASE_OPTIONS+=(--prerelease)
else else

View File

@ -30,7 +30,10 @@ fi
) >"$LOG_FILE" 2>&1 & ) >"$LOG_FILE" 2>&1 &
SERVER_PID="$!" SERVER_PID="$!"
for _ in $(seq 1 80); do # A cold Go cache can take longer than 20 seconds to compile `go run`; wait
# for the real listener rather than treating a still-running compiler as a
# healthy server.
for _ in $(seq 1 300); do
if curl -fsS "http://127.0.0.1:$PORT/api/v1/health" >/dev/null 2>&1; then if curl -fsS "http://127.0.0.1:$PORT/api/v1/health" >/dev/null 2>&1; then
break break
fi fi
@ -45,10 +48,12 @@ curl -fsS "http://127.0.0.1:$PORT/api/v1/health" >/dev/null
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)" NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
sqlite3 "$DATA_DIR/server.db" " sqlite3 "$DATA_DIR/server.db" "
INSERT INTO server_devices (id, name, api_key, last_seen, created_at) INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at)
VALUES ('smoke-device-a', 'Smoke Device A', 'smoke-key-a', '$NOW', '$NOW'); VALUES ('smoke-user', 'smoke-user', 'smoke@example.test', 'unused', 1, '$NOW');
INSERT INTO server_devices (id, name, api_key, last_seen, created_at) INSERT INTO server_devices (id, name, api_key, legacy_api_key, user_id, vault_id, last_seen, created_at)
VALUES ('smoke-device-b', 'Smoke Device B', 'smoke-key-b', '$NOW', '$NOW'); VALUES ('smoke-device-a', 'Smoke Device A', 'smoke-key-a', 1, 'smoke-user', 'smoke-vault', '$NOW', '$NOW');
INSERT INTO server_devices (id, name, api_key, legacy_api_key, user_id, vault_id, last_seen, created_at)
VALUES ('smoke-device-b', 'Smoke Device B', 'smoke-key-b', 1, 'smoke-user', 'smoke-vault', '$NOW', '$NOW');
" "
( (

22
scripts/test-brand-icons.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ICONS=(
"$ROOT/internal/shell/tray/verstak.png"
"$ROOT/internal/shell/tray/verstak.ico"
"$ROOT/build/appicon.png"
"$ROOT/build/windows/icon.ico"
)
"$ROOT/scripts/generate-brand-icons.sh"
first="$(sha256sum "${ICONS[@]}")"
"$ROOT/scripts/generate-brand-icons.sh"
second="$(sha256sum "${ICONS[@]}")"
if [[ "$first" != "$second" ]]; then
echo "brand icon generation is not deterministic" >&2
exit 1
fi
echo "desktop brand icon generation is deterministic"

View File

@ -26,11 +26,16 @@ grep -Fq 'gtk-update-icon-cache' "$ROOT/packaging/deb/postinst"
grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh" grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun" grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control" grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control"
grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control" if grep -Fq 'appindicator' "$ROOT/packaging/deb/control"; then
echo "Debian package must not require the removed AppIndicator tray backend" >&2
exit 1
fi
grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh" grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh" grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'libayatana-appindicator3.so.' "$ROOT/scripts/package-appimage.sh" if grep -Fq 'appindicator' "$ROOT/scripts/package-appimage.sh" "$ROOT/scripts/build.sh"; then
grep -Fq 'ayatana-appindicator3-0.1' "$ROOT/scripts/build.sh" echo "Linux build and AppImage scripts must not require AppIndicator" >&2
exit 1
fi
if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle Fixed Version WebView2" >&2 echo "Windows portable archive must not bundle Fixed Version WebView2" >&2
exit 1 exit 1
@ -48,6 +53,9 @@ grep -Fq 'LinkId=2124701' "$ROOT/README.md"
grep -Fq 'WebView2 Runtime' "$ROOT/README.md" grep -Fq 'WebView2 Runtime' "$ROOT/README.md"
grep -Fq 'package-deb.sh' "$ROOT/scripts/release.sh" grep -Fq 'package-deb.sh' "$ROOT/scripts/release.sh"
grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh" grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh"
grep -Fq 'generate-brand-icons.sh' "$ROOT/scripts/build.sh"
grep -Fq 'generate-brand-icons.sh' "$ROOT/scripts/build-windows.sh"
test -x "$ROOT/scripts/test-brand-icons.sh"
grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh" grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh"
git -C "$ROOT" check-ignore -q verstak-desktop-res.syso git -C "$ROOT" check-ignore -q verstak-desktop-res.syso
grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh" grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh"

View File

@ -18,9 +18,10 @@ if [[ ! -x "$PUBLISHER" ]]; then
exit 1 exit 1
fi fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state" mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state"
LOG="$WORK/log" LOG="$WORK/log"
export LOG export LOG
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
cat > "$WORK/release.sh" <<'SCRIPT' cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
@ -51,6 +52,7 @@ case "${1:-}" in
fi fi
echo test-commit echo test-commit
;; ;;
describe) echo v0.0.0-previous ;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;; tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;; push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;; *) echo "unexpected git invocation: $*" >&2; exit 1 ;;
@ -76,6 +78,7 @@ chmod +x "$WORK/bin/gh"
run_publisher() { run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \ VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \ VERSTAK_RELEASE_DIR="$WORK/release" \
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
GIT_BIN="$WORK/bin/git" \ GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \ GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \ EXPECTED_ROOT="$ROOT" \
@ -92,6 +95,9 @@ for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null grep -F "$asset" "$LOG" >/dev/null
done done
grep -F "SHA256SUMS" "$LOG" >/dev/null grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--notes-file $WORK/release-notes/$VERSION.md" "$LOG" >/dev/null
grep -F -- "--generate-notes" "$LOG" >/dev/null
grep -F -- "--notes-start-tag v0.0.0-previous" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher run_publisher

View File

@ -39,6 +39,10 @@ OUTPUT=$(cd "$ROOT" && go test -count=1 -v ./... 2>&1) || GO_TEST_STATUS=$?
echo "$OUTPUT" | grep -E '(FAIL|PASS|---)' || true echo "$OUTPUT" | grep -E '(FAIL|PASS|---)' || true
report "go test" "$GO_TEST_STATUS" report "go test" "$GO_TEST_STATUS"
BRAND_ICONS_STATUS=0
(cd "$ROOT" && ./scripts/test-brand-icons.sh) || BRAND_ICONS_STATUS=$?
report "desktop brand icon generation" "$BRAND_ICONS_STATUS"
WAILS_BINDINGS_STATUS=0 WAILS_BINDINGS_STATUS=0
(cd "$ROOT" && node frontend/tests/wails-bindings-test.mjs) || WAILS_BINDINGS_STATUS=$? (cd "$ROOT" && node frontend/tests/wails-bindings-test.mjs) || WAILS_BINDINGS_STATUS=$?
report "Wails notification bindings" "$WAILS_BINDINGS_STATUS" report "Wails notification bindings" "$WAILS_BINDINGS_STATUS"