Compare commits
9 Commits
v0.1.0-alp
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
3bf67fb1e9 | |
|
|
2ce87f6fb1 | |
|
|
0b5fd1cac5 | |
|
|
b10f243e34 | |
|
|
1ca759cc2e | |
|
|
4ae08ef88e | |
|
|
e3d8078ad5 | |
|
|
ba0ba5f8c4 | |
|
|
bf965e8bf3 |
28
README.md
28
README.md
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
### Requirements
|
||||
|
|
@ -232,6 +259,7 @@ Each vault is connected separately. The local vault remains the primary copy of
|
|||
* Node.js 20 or newer with npm;
|
||||
* Python 3;
|
||||
* Git;
|
||||
* ImageMagick (`magick`) to generate the branded tray and application icons;
|
||||
* Wails v2 build dependencies;
|
||||
* WebKitGTK development packages on Linux.
|
||||
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ sha256sum -c SHA256SUMS --ignore-missing
|
|||
* Node.js 20 или новее и npm;
|
||||
* Python 3;
|
||||
* Git;
|
||||
* ImageMagick (`magick`) для генерации фирменных значков трея и приложения;
|
||||
* зависимости для сборки Wails v2;
|
||||
* пакеты разработки WebKitGTK в Linux.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -1,39 +1,30 @@
|
|||
# Notes/Files Plugin Architecture Plan
|
||||
|
||||
This document locks the Notes/Files/Open architecture for the next milestones.
|
||||
Files Core Service was implemented in Milestone 6a; this document still does not
|
||||
start Notes UI, Notes plugin, Files UI plugin, editor plugin, watcher, sync, or
|
||||
binary streaming.
|
||||
This document captures the architectural decisions for Notes, Files, and
|
||||
Workbench routing. Most items described below are now implemented; this
|
||||
document remains as a reference for the canonical data model and rules.
|
||||
|
||||
## Current Readiness
|
||||
|
||||
The platform is ready for bundled plugin UI experiments. Files Core is available
|
||||
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.
|
||||
All key components are implemented:
|
||||
|
||||
Already available:
|
||||
|
||||
- Plugin discovery, lifecycle, settings, capabilities, bundled commands, and
|
||||
bundled frontend events.
|
||||
- Workspace lifecycle APIs for top-level physical folders under the vault root.
|
||||
- Plugin-owned internal storage directories:
|
||||
`.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.
|
||||
- Files Core API with safe vault-scoped text and binary file operations.
|
||||
- Notes plugin as a semantic view over canonical `Notes/` Markdown files.
|
||||
- Files UI plugin with folder navigation and Workbench-based file opening.
|
||||
- Default Editor plugin providing text/markdown openProviders.
|
||||
- File Preview plugin for images.
|
||||
- Workbench open/edit routing with provider selection.
|
||||
- UI hosts for file actions, note actions, context menus, search providers,
|
||||
activity providers, or status bar items.
|
||||
- Watcher/indexer for external filesystem changes.
|
||||
- Real plugin isolation. Current permission checks are contract/policy checks,
|
||||
not a security boundary for bundled frontend JavaScript.
|
||||
activity providers, and status bar items.
|
||||
- File watcher for external filesystem changes.
|
||||
- Trash plugin for restore and permanent delete.
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -477,8 +477,8 @@ contributions summary.
|
|||
- All paths are canonical vault-relative slash paths. Backslashes, POSIX
|
||||
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
|
||||
symlink traversal, and public access to `.verstak/` are rejected.
|
||||
- `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any
|
||||
first path segment with that spelling are internal-only.
|
||||
- `.verstak` is reserved case-insensitively in every path segment: its marker,
|
||||
sync snapshot, trash, and workspace identity are internal-only.
|
||||
- `files.metadata` may report a final symlink as `type: "symlink"`, but
|
||||
`files.list` through a symlink directory and all read/write/move/trash
|
||||
operations through symlinks are forbidden in Milestone 6a.
|
||||
|
|
@ -486,20 +486,39 @@ contributions summary.
|
|||
`writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is
|
||||
deferred.
|
||||
- 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
|
||||
external creates, updates, and deletes outside `.verstak/`. It does not keep a
|
||||
persistent snapshot or report what changed while Verstak was closed.
|
||||
It publishes `file.changed` as a UI hint and debounces a full core scanner;
|
||||
the persistent `.verstak/sync/snapshot.json` scanner, not the watcher, is the
|
||||
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.now()` pushes local operations, pulls remote operations, and returns
|
||||
`{ pushed, pulled, serverSequence, conflicts?, applyErrors? }`.
|
||||
- `sync.now()` scans local files, pulls/reconciles in `server_sequence` order,
|
||||
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
|
||||
may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional
|
||||
server fields. The Sync plugin must show conflict details instead of only a
|
||||
count, and it must not silently resolve or overwrite local data.
|
||||
- `applyErrors` lists local apply failures for pulled operations. These are
|
||||
user-visible warnings and do not imply that sync was fully successful.
|
||||
- `sync.status()` includes `vaultId` (the paired remote scope) and
|
||||
`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
|
||||
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.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
|
||||
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
|
||||
| `api.sync.now()` | ✅ Работает | Push/pull с bounded retry/backoff для transient HTTP/network failures |
|
||||
| `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked state, lastError, unpushed count |
|
||||
| `api.sync.now()` | ✅ Работает | Snapshot scan, строгий ordered pull/retry и bounded retry/backoff для transient HTTP/network failures |
|
||||
| `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked, remote `vaultId`, lastError/lastWarning и unpushed count |
|
||||
| `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/events/bus.go` | EventBus |
|
||||
| `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_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 |
|
||||
|
|
|
|||
|
|
@ -1,350 +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, `fyne.io/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 after the tray reports ready; otherwise normal
|
||||
window close 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**
|
||||
|
||||
Use `fyne.io/systray`. The production adapter calls `RunWithExternalLoop` so the
|
||||
Windows native message loop runs alongside Wails, embeds a multi-resolution
|
||||
ICO on Windows and PNG on Linux, routes one left click to `app.ShowWindow`, and
|
||||
leaves the platform-native right-click menu active. `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
|
||||
! rg -n 'getlantern|appindicator' go.mod go.sum packaging scripts/build.sh
|
||||
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 that the Linux build guidance, Debian metadata, and AppImage
|
||||
packager no longer require the removed AppIndicator backend.
|
||||
|
||||
- [ ] **Step 2: Confirm red**
|
||||
|
||||
Run: `./scripts/test-package-formats.sh`
|
||||
|
||||
Expected: the first tray dependency assertion fails.
|
||||
|
||||
- [ ] **Step 3: Implement portable package support**
|
||||
|
||||
Keep Debian and AppImage focused on the Wails/WebKitGTK runtime. Do not add a
|
||||
tray-specific AppIndicator dependency. 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.
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
# Native Notifications and System Tray Design
|
||||
|
||||
**Status:** implemented; tray reliability update recorded on 2026-07-15
|
||||
|
||||
## 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 initialized after Wails reaches
|
||||
`OnDomReady`. Its menu contains exactly two actions:
|
||||
|
||||
1. **Show Verstak** — shows and focuses the existing main window.
|
||||
2. **Quit** — exits the process deliberately.
|
||||
|
||||
One left click restores and focuses the existing main window. A native right
|
||||
click opens the menu. Closing the main window with its window-manager close
|
||||
control hides the window only after the tray has successfully initialized; it
|
||||
then keeps the process, plugins, local browser receiver, and reminder scheduler
|
||||
alive. If tray initialization fails or the native message loop exits, the
|
||||
ordinary close path exits normally rather than leaving an unreachable process.
|
||||
The quit action allows the close lifecycle to finish and 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 `fyne.io/systray` through a small
|
||||
`internal/shell/tray` adapter. `RunWithExternalLoop` starts the Windows native
|
||||
message loop without making Wails relinquish ownership of its GUI lifecycle.
|
||||
The icon is a source-controlled multi-resolution ICO on Windows (16, 20, 24,
|
||||
32, 48, and 256 pixels with transparency) and a PNG on Linux. Both are embedded
|
||||
in the binary, so a clean build does not depend on ignored Wails-generated
|
||||
files. Tray readiness is published only after icon, tooltip, and menu creation
|
||||
all succeed; lifecycle diagnostics are logged for startup, readiness, clicks,
|
||||
failure fallback, and shutdown.
|
||||
|
||||
## 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
|
||||
|
||||
`fyne.io/systray` supplies the Windows message loop and uses the session D-Bus
|
||||
on Linux. It does not require the removed AppIndicator development or runtime
|
||||
package. The Windows release build still uses `x86_64-w64-mingw32-gcc` for the
|
||||
Wails application itself.
|
||||
|
||||
The existing AppImage packager traverses `ldd` for the desktop executable and
|
||||
copies non-glibc runtime libraries; it does not require a tray-specific shared
|
||||
library.
|
||||
|
||||
## 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 only after tray readiness, while explicit
|
||||
quit permits shutdown;
|
||||
- tray controller action wiring, readiness/failure fallback, idempotent stop,
|
||||
left-click reveal, and second-instance window reveal;
|
||||
- true multi-resolution Windows ICO data, Linux PNG data, and Linux/Windows
|
||||
build 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, verify the tray icon and tooltip, use
|
||||
one left click to reveal the window, use the right-click menu to reveal it,
|
||||
close the window, 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.
|
||||
|
|
@ -99,7 +99,7 @@ test.describe('Command Palette', () => {
|
|||
|
||||
test('runs sync workflow commands', async ({ page }) => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ test.describe('D: Plugin API bridge', () => {
|
|||
const api = window.createPluginAPI('verstak.sync');
|
||||
const initial = await api.sync.status();
|
||||
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);
|
||||
const configured = await api.sync.status();
|
||||
const syncNow = await api.sync.now();
|
||||
|
|
@ -117,6 +117,8 @@ test.describe('D: Plugin API bridge', () => {
|
|||
expect(result.initial.statusLabel).toBe('disabled');
|
||||
expect(result.configured.configured).toBe(true);
|
||||
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.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 });
|
||||
expect(result.reset.configured).toBe(false);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,15 @@ export default {
|
|||
'workspaceTree.createError': 'Could not create 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.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.reloadError': 'Could not reload plugins. Please try again.',
|
||||
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
|
||||
|
|
|
|||
|
|
@ -160,6 +160,15 @@ export default {
|
|||
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
|
||||
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
|
||||
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
|
||||
'workspaceTree.parentFolder': 'Родительская папка',
|
||||
'workspaceTree.rootFolder': '(корень vault)',
|
||||
'workspaceTree.folderTitle': 'Папки',
|
||||
'workspaceTree.folderEmpty': 'Здесь нет Дел',
|
||||
'workspaceTree.createFolder': 'Создать папку',
|
||||
'workspaceTree.folderNamePlaceholder': 'Название папки',
|
||||
'workspaceTree.folderIcon': 'Иконка',
|
||||
'workspaceTree.folderColor': 'Цвет',
|
||||
'workspaceTree.folderCreateError': 'Не удалось создать папку. Повторите попытку.',
|
||||
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
|
||||
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
|
||||
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
|
||||
|
|
|
|||
|
|
@ -468,10 +468,10 @@ export function createPluginAPI(pluginId) {
|
|||
return App.PluginSyncStatus(pluginId);
|
||||
});
|
||||
},
|
||||
configure: function(serverURL, username, password) {
|
||||
configure: function(serverURL, username, password, vaultId) {
|
||||
assertActive('sync.configure');
|
||||
return callBackendErrorString(pluginId, 'sync.configure', function() {
|
||||
return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || '');
|
||||
return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || '', vaultId || '');
|
||||
});
|
||||
},
|
||||
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() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { onDestroy, onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import WorkspaceTree from './WorkspaceTree.svelte';
|
||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||
import GlobalSearch from './GlobalSearch.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { debug } from '../log/debug.js';
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
let plugins = [];
|
||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||
let sidebarItems = [];
|
||||
let workspaceTreeProvider = null;
|
||||
let errorMessage = '';
|
||||
let locale = i18n.getLocale();
|
||||
let unsubscribeLocale = null;
|
||||
|
|
@ -59,6 +61,19 @@
|
|||
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));
|
||||
|
||||
// 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);
|
||||
flog('onMount: sidebarItems=' + sidebarItems.length);
|
||||
} catch (e) {
|
||||
|
|
@ -123,8 +138,12 @@
|
|||
{/if}
|
||||
|
||||
{#if vaultOpen}
|
||||
{#if workspaceTreeProvider}
|
||||
<PluginBundleHost pluginId={workspaceTreeProvider.pluginId} componentId={workspaceTreeProvider.component} />
|
||||
{:else}
|
||||
<WorkspaceTree />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="sidebar-footer">
|
||||
{#if errorMessage}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
let loading = true;
|
||||
let localError = '';
|
||||
let workspaces = [];
|
||||
let treeNodes = [];
|
||||
let currentWorkspaceId = '';
|
||||
let showCreate = false;
|
||||
let newWorkspaceName = '';
|
||||
|
|
@ -25,11 +26,12 @@
|
|||
let templateWarning = null;
|
||||
let templatesLoading = false;
|
||||
let creating = false;
|
||||
let renamingId = '';
|
||||
let renameValue = '';
|
||||
let busyId = '';
|
||||
let expandedFolders = {};
|
||||
let parentFolderPath = '';
|
||||
let locale = i18n.getLocale();
|
||||
let unsubscribeLocale = null;
|
||||
|
||||
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||
void activeLocale;
|
||||
return i18n.t(key, params, fallback);
|
||||
|
|
@ -81,45 +83,20 @@
|
|||
function templateToolState(pluginId, plugins, capabilities, names, translate) {
|
||||
const plugin = plugins[pluginId];
|
||||
if (!plugin) {
|
||||
return {
|
||||
pluginId,
|
||||
name: toolLabel(pluginId, names),
|
||||
tabs: [],
|
||||
status: 'unavailable',
|
||||
reason: translate('workspaceTree.templateMissingPlugin'),
|
||||
};
|
||||
return { pluginId, name: toolLabel(pluginId, names), tabs: [], status: 'unavailable', reason: translate('workspaceTree.templateMissingPlugin') };
|
||||
}
|
||||
|
||||
const manifest = plugin.manifest || {};
|
||||
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
|
||||
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
|
||||
: [];
|
||||
const tabs = Array.isArray(manifest.contributes?.workspaceItems) ? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean) : [];
|
||||
const pluginStatus = String(plugin.status || '').toLowerCase();
|
||||
const missingCapability = Array.isArray(manifest.requires)
|
||||
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
|
||||
const missingCapability = Array.isArray(manifest.requires) && manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
|
||||
let status = 'available';
|
||||
let reason = translate('workspaceTree.templateAvailable');
|
||||
|
||||
if (!plugin.enabled || pluginStatus === 'disabled') {
|
||||
status = 'unavailable';
|
||||
reason = translate('workspaceTree.templatePluginDisabled');
|
||||
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
|
||||
status = 'unavailable';
|
||||
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');
|
||||
}
|
||||
|
||||
if (!plugin.enabled || pluginStatus === 'disabled') { status = 'unavailable'; reason = translate('workspaceTree.templatePluginDisabled'); }
|
||||
else if (pluginStatus === 'missing-required-capability' || missingCapability) { status = 'unavailable'; 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 };
|
||||
}
|
||||
|
||||
|
|
@ -132,59 +109,129 @@
|
|||
App.GetCapabilities ? App.GetCapabilities() : [],
|
||||
]);
|
||||
const [list, err] = resultOrError(templates, []);
|
||||
if (err) {
|
||||
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err);
|
||||
workspaceTemplates = [];
|
||||
return;
|
||||
}
|
||||
if (err) { createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates.', err); workspaceTemplates = []; return; }
|
||||
workspaceTemplates = Array.isArray(list) ? list : [];
|
||||
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
|
||||
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||
)));
|
||||
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})));
|
||||
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
|
||||
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
|
||||
const id = plugin?.manifest?.id;
|
||||
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;
|
||||
}, {});
|
||||
templatePluginNames = localizedPlugins.reduce((names, plugin) => { const id = plugin?.manifest?.id; 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, []);
|
||||
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
|
||||
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
|
||||
selectedTemplateId = workspaceTemplates[0]?.id || '';
|
||||
}
|
||||
} catch (error) {
|
||||
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error);
|
||||
workspaceTemplates = [];
|
||||
} finally {
|
||||
templatesLoading = false;
|
||||
}
|
||||
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) selectedTemplateId = workspaceTemplates[0]?.id || '';
|
||||
} catch (error) { createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates.', error); workspaceTemplates = []; }
|
||||
finally { templatesLoading = false; }
|
||||
}
|
||||
|
||||
function wsName(workspace) {
|
||||
return String(workspace?.name || workspace?.rootPath || '');
|
||||
return String(workspace?.name || workspace?.path || '');
|
||||
}
|
||||
|
||||
function asNode(workspace, order) {
|
||||
const name = wsName(workspace);
|
||||
return {
|
||||
id: name,
|
||||
function wsPath(workspace) {
|
||||
return String(workspace?.path || workspace?.rootPath || '');
|
||||
}
|
||||
|
||||
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',
|
||||
title: name,
|
||||
name,
|
||||
rootPath: workspace.rootPath || name,
|
||||
name: name,
|
||||
path: path,
|
||||
rootPath: path,
|
||||
parentId: parentId,
|
||||
status: 'active',
|
||||
order,
|
||||
};
|
||||
order: nodes.length,
|
||||
workspace: ws,
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
@ -192,82 +239,92 @@
|
|||
localError = '';
|
||||
try {
|
||||
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
|
||||
if (err) {
|
||||
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err);
|
||||
workspaces = [];
|
||||
} else {
|
||||
workspaces = list || [];
|
||||
if (err) { localError = reportError('workspaceTree.loadError', 'Could not load Deals.', err); workspaces = []; }
|
||||
else { workspaces = list || []; }
|
||||
|
||||
// Try to get tree nodes
|
||||
try {
|
||||
const tree = await App.GetWorkspaceTree();
|
||||
if (tree && tree.nodes) treeNodes = tree.nodes;
|
||||
} catch { treeNodes = []; }
|
||||
|
||||
const allNodes = buildTreeNodes(workspaces);
|
||||
|
||||
if (!currentWorkspaceId) {
|
||||
let currentWorkspace = null;
|
||||
try {
|
||||
currentWorkspace = await App.GetCurrentWorkspace();
|
||||
} catch {
|
||||
currentWorkspace = null;
|
||||
}
|
||||
const currentName = wsName(currentWorkspace);
|
||||
if (workspaces.some((ws) => wsName(ws) === currentName)) {
|
||||
currentWorkspaceId = currentName;
|
||||
}
|
||||
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
|
||||
try { currentWorkspace = await App.GetCurrentWorkspace(); } catch { currentWorkspace = null; }
|
||||
const currentPath = currentWorkspace?.path || currentWorkspace?.rootPath || '';
|
||||
if (allNodes.some(n => n.id === currentPath)) currentWorkspaceId = currentPath;
|
||||
} else if (!allNodes.some(n => n.id === currentWorkspaceId)) {
|
||||
currentWorkspaceId = '';
|
||||
}
|
||||
activeWorkspaceId.set(currentWorkspaceId);
|
||||
}
|
||||
} catch (e) {
|
||||
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e);
|
||||
}
|
||||
workspaces = allNodes;
|
||||
} catch (e) { localError = reportError('workspaceTree.loadError', 'Could not load Deals.', e); }
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function selectWorkspace(workspace) {
|
||||
const id = wsName(workspace);
|
||||
const err = await App.SetCurrentWorkspace(id);
|
||||
if (err) {
|
||||
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err);
|
||||
return;
|
||||
function getChildren(parentId) {
|
||||
const id = parentId || null;
|
||||
return workspaces.filter(n => (n.parentId || null) === id).sort((a, b) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
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;
|
||||
activeWorkspaceId.set(id);
|
||||
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() {
|
||||
const name = newWorkspaceName.trim();
|
||||
if (!name) {
|
||||
createError = tr('workspaceTree.nameRequired');
|
||||
return;
|
||||
}
|
||||
if (!selectedTemplate) {
|
||||
createError = tr('workspaceTree.chooseTemplate');
|
||||
return;
|
||||
}
|
||||
const creationIssues = selectedTemplateIssues.map(tool => ({
|
||||
pluginId: tool.pluginId,
|
||||
name: tool.name,
|
||||
reason: tool.reason,
|
||||
}));
|
||||
if (!name) { createError = tr('workspaceTree.nameRequired'); return; }
|
||||
if (!selectedTemplate) { createError = tr('workspaceTree.chooseTemplate'); return; }
|
||||
const path = buildCreatePath();
|
||||
creating = true;
|
||||
createError = '';
|
||||
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
|
||||
if (err) {
|
||||
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err);
|
||||
creating = false;
|
||||
return;
|
||||
}
|
||||
const [, err] = resultOrError(await App.CreateWorkspace(path, selectedTemplate.id), null);
|
||||
if (err) { createError = reportError('workspaceTree.createError', 'Could not create the Deal.', err); creating = false; return; }
|
||||
showCreate = false;
|
||||
newWorkspaceName = '';
|
||||
parentFolderPath = '';
|
||||
creating = false;
|
||||
await loadWorkspaces();
|
||||
const created = workspaces.find((ws) => wsName(ws) === name);
|
||||
const created = workspaces.find((ws) => ws.id === path);
|
||||
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;
|
||||
}
|
||||
|
||||
async function openCreateDialog() {
|
||||
showCreate = true;
|
||||
newWorkspaceName = '';
|
||||
parentFolderPath = '';
|
||||
createError = '';
|
||||
await loadWorkspaceTemplates();
|
||||
}
|
||||
|
|
@ -276,64 +333,48 @@
|
|||
if (creating) return;
|
||||
showCreate = false;
|
||||
newWorkspaceName = '';
|
||||
parentFolderPath = '';
|
||||
createError = '';
|
||||
}
|
||||
|
||||
function dismissTemplateWarning() {
|
||||
templateWarning = null;
|
||||
function dismissTemplateWarning() { 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) {
|
||||
renamingId = wsName(workspace);
|
||||
renameValue = renamingId;
|
||||
localError = '';
|
||||
}
|
||||
function cancelRename() { }
|
||||
|
||||
function cancelRename() {
|
||||
renamingId = '';
|
||||
renameValue = '';
|
||||
}
|
||||
async function commitRename() { }
|
||||
|
||||
async function commitRename(workspace) {
|
||||
const oldName = wsName(workspace);
|
||||
const newName = renameValue.trim();
|
||||
if (!newName || newName === oldName) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
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 = '';
|
||||
async function trashWorkspace(node) {
|
||||
const path = node.id;
|
||||
busyId = path;
|
||||
const [, err] = resultOrError(await App.TrashWorkspace(path), null);
|
||||
if (err) { localError = reportError('workspaceTree.trashError', 'Could not move to trash.', err); busyId = ''; return; }
|
||||
if (currentWorkspaceId === path) currentWorkspaceId = '';
|
||||
busyId = '';
|
||||
await loadWorkspaces();
|
||||
if (currentWorkspaceId) {
|
||||
const selected = workspaces.find((ws) => wsName(ws) === currentWorkspaceId);
|
||||
const selected = workspaces.find((ws) => ws.id === currentWorkspaceId);
|
||||
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>
|
||||
|
||||
<div class="wt">
|
||||
|
|
@ -349,34 +390,40 @@
|
|||
{/if}
|
||||
|
||||
<div class="wt-list">
|
||||
{#each workspaces as workspace (wsName(workspace))}
|
||||
{@const id = wsName(workspace)}
|
||||
<div class="wt-node vt-list-row" class:selected={id === $activeWorkspaceId}>
|
||||
{#each getChildren(null) as node (node.id)}
|
||||
{#if node.type === 'folder'}
|
||||
{@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">
|
||||
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
|
||||
{#if renamingId === id}
|
||||
<input
|
||||
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}>
|
||||
<button class="wt-label" on:click={() => selectWorkspace(child)} type="button">{child.title}</button>
|
||||
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(child)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === child.id}>
|
||||
<Icon name="trash" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</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>
|
||||
{/if}
|
||||
{/each}
|
||||
</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-modal">
|
||||
<div class="workspace-create-header">
|
||||
<div>
|
||||
<h2>{tr('workspaceTree.new')}</h2>
|
||||
</div>
|
||||
<div><h2>{tr('workspaceTree.new')}</h2></div>
|
||||
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
|
||||
</div>
|
||||
<label class="workspace-create-field">
|
||||
<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()} />
|
||||
</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">
|
||||
<span>{tr('workspaceTree.template')}</span>
|
||||
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
|
||||
|
|
@ -418,13 +471,7 @@
|
|||
<p data-workspace-template-description>{selectedTemplate.description}</p>
|
||||
<div class="workspace-template-tools" data-workspace-template-tools>
|
||||
{#each selectedTemplateTools as tool (tool.pluginId)}
|
||||
<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}
|
||||
>
|
||||
<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}>
|
||||
<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-reason">{tool.reason}</span>
|
||||
|
|
@ -455,24 +502,23 @@
|
|||
.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: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-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: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-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; }
|
||||
.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-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-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-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-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; }
|
||||
|
|
|
|||
|
|
@ -626,6 +626,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||
return {
|
||||
configured: false,
|
||||
serverUrl: '',
|
||||
vaultId: '',
|
||||
deviceId: 'mock-device',
|
||||
deviceName: '',
|
||||
connected: false,
|
||||
|
|
@ -635,6 +636,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||
lastSyncAt: '',
|
||||
syncInterval: 0,
|
||||
lastError: '',
|
||||
lastWarning: '',
|
||||
statusLabel: 'disabled',
|
||||
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' };
|
||||
var parts = p.split('/').filter(Boolean);
|
||||
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('/') };
|
||||
}
|
||||
|
||||
|
|
@ -751,6 +753,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||
return {
|
||||
configured: syncState.configured,
|
||||
serverUrl: syncState.serverUrl,
|
||||
vaultId: syncState.vaultId,
|
||||
deviceId: syncState.deviceId,
|
||||
deviceName: syncState.deviceName,
|
||||
connected: syncState.connected,
|
||||
|
|
@ -760,6 +763,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||
lastSyncAt: syncState.lastSyncAt,
|
||||
syncInterval: syncState.syncInterval,
|
||||
lastError: syncState.lastError,
|
||||
lastWarning: syncState.lastWarning,
|
||||
statusLabel: syncState.statusLabel
|
||||
};
|
||||
}
|
||||
|
|
@ -3477,11 +3481,12 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||
if (err) return Promise.resolve([{}, err]);
|
||||
return Promise.resolve([syncStatusDTO(), '']);
|
||||
},
|
||||
PluginSyncConfigure: function (pluginId, serverUrl) {
|
||||
PluginSyncConfigure: function (pluginId, serverUrl, username, password, vaultId) {
|
||||
var err = requirePluginSyncPermission(pluginId, true);
|
||||
if (err) return Promise.resolve(err);
|
||||
syncState.configured = true;
|
||||
syncState.serverUrl = serverUrl || '';
|
||||
syncState.vaultId = vaultId || 'test-vault-001';
|
||||
syncState.deviceId = 'mock-device';
|
||||
syncState.deviceName = 'mock-device';
|
||||
syncState.connected = true;
|
||||
|
|
@ -3491,6 +3496,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||
syncState.statusLabel = 'connected';
|
||||
pluginSettings[pluginId] = Object.assign({}, pluginSettings[pluginId] || {}, {
|
||||
serverUrl: syncState.serverUrl,
|
||||
vaultId: syncState.vaultId,
|
||||
syncStatus: syncState.statusLabel
|
||||
});
|
||||
return Promise.resolve('');
|
||||
|
|
|
|||
|
|
@ -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 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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -210,8 +210,8 @@ export function PluginSecretsWrite(arg1, arg2) {
|
|||
return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function PluginSyncConfigure(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4);
|
||||
export function PluginSyncConfigure(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function PluginSyncDisconnect(arg1) {
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@ export namespace api {
|
|||
export class SyncStatusDTO {
|
||||
configured: boolean;
|
||||
serverUrl: string;
|
||||
vaultId: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
connected: boolean;
|
||||
|
|
@ -319,6 +320,7 @@ export namespace api {
|
|||
lastSyncAt: string;
|
||||
syncInterval: number;
|
||||
lastError: string;
|
||||
lastWarning: string;
|
||||
statusLabel: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
|
|
@ -329,6 +331,7 @@ export namespace api {
|
|||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.configured = source["configured"];
|
||||
this.serverUrl = source["serverUrl"];
|
||||
this.vaultId = source["vaultId"];
|
||||
this.deviceId = source["deviceId"];
|
||||
this.deviceName = source["deviceName"];
|
||||
this.connected = source["connected"];
|
||||
|
|
@ -338,6 +341,7 @@ export namespace api {
|
|||
this.lastSyncAt = source["lastSyncAt"];
|
||||
this.syncInterval = source["syncInterval"];
|
||||
this.lastError = source["lastError"];
|
||||
this.lastWarning = source["lastWarning"];
|
||||
this.statusLabel = source["statusLabel"];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -175,6 +175,12 @@ func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, s
|
|||
t.Helper()
|
||||
app, root := newFilesTestApp(t, perms)
|
||||
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"))
|
||||
if err := app.appSettings.Load(); err != nil {
|
||||
t.Fatalf("settings Load: %v", err)
|
||||
|
|
@ -1692,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) {
|
||||
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
|
||||
|
||||
|
|
@ -1718,8 +1781,8 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetUnpushedOps: %v", err)
|
||||
}
|
||||
if len(ops) != 6 {
|
||||
t.Fatalf("ops len = %d, want 6: %#v", len(ops), ops)
|
||||
if len(ops) != 7 {
|
||||
t.Fatalf("ops len = %d, want 7: %#v", len(ops), ops)
|
||||
}
|
||||
|
||||
want := []struct {
|
||||
|
|
@ -1731,8 +1794,11 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
|
|||
{syncsvc.EntityFolder, "Docs", syncsvc.OpCreate, `"path":"Docs"`},
|
||||
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, `"content":"hello"`},
|
||||
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpUpdate, `"content":"updated"`},
|
||||
{syncsvc.EntityFile, "Docs/image.bin", syncsvc.OpCreate, `"dataBase64":"AQID"`},
|
||||
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpMove, `"toPath":"Docs/two.txt"`},
|
||||
{syncsvc.EntityFile, "Docs/image.bin", syncsvc.OpCreate, `"blob":{"sha256":`},
|
||||
// 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"`},
|
||||
}
|
||||
for i, w := range want {
|
||||
|
|
@ -1854,8 +1920,8 @@ func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) {
|
|||
t.Fatalf("pushed ops len = %d, want 2", len(pushedOps))
|
||||
}
|
||||
for i, op := range pushedOps {
|
||||
if op.LastSeenServerSeq != 0 {
|
||||
t.Fatalf("pushed op[%d] last seen = %d, want 0", i, op.LastSeenServerSeq)
|
||||
if op.LastSeenServerSeq != 2 {
|
||||
t.Fatalf("pushed op[%d] last seen = %d, want 2 after initial pull", i, op.LastSeenServerSeq)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1882,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) {
|
||||
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
|
||||
meta := app.vault.GetVaultMeta()
|
||||
|
|
@ -1911,7 +2340,7 @@ func TestSyncConfigurePairsCurrentVaultID(t *testing.T) {
|
|||
}))
|
||||
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)
|
||||
}
|
||||
if pairedVaultID != meta.VaultID {
|
||||
|
|
@ -1919,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) {
|
||||
app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "wrong-global-device")
|
||||
var pushedDeviceID string
|
||||
|
|
@ -2122,9 +2624,6 @@ func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
|
|||
if len(nodes) == 0 {
|
||||
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") {
|
||||
t.Fatal("workspace capability should be registered after SetCurrentVault")
|
||||
}
|
||||
|
|
@ -2403,11 +2902,11 @@ func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
|
|||
}
|
||||
|
||||
errStr := app.MoveWorkspaceNode("Project", "Test")
|
||||
if errStr == "" || !strings.Contains(errStr, "top-level only") {
|
||||
t.Fatalf("MoveWorkspaceNode error = %q, want top-level only", errStr)
|
||||
if errStr == "" || !strings.Contains(errStr, "parent-is-workspace") {
|
||||
t.Fatalf("MoveWorkspaceNode error = %q, want parent-is-workspace", errStr)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
appA, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceA)
|
||||
appB, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceB)
|
||||
appA, rootA := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceA)
|
||||
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 {
|
||||
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 != "" {
|
||||
t.Fatalf("appB move: %s", errStr)
|
||||
}
|
||||
expectSyncCounts(t, appB, 2, 2)
|
||||
expectSyncCounts(t, appA, 0, 2)
|
||||
expectSyncCounts(t, appB, 3, 3)
|
||||
expectSyncCounts(t, appA, 0, 3)
|
||||
expectText(t, appA, "Shared/two.txt", "from B")
|
||||
|
||||
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 != "" {
|
||||
t.Fatalf("appA move folder: %s", errStr)
|
||||
}
|
||||
expectSyncCounts(t, appA, 1, 1)
|
||||
expectSyncCounts(t, appB, 0, 1)
|
||||
expectSyncCounts(t, appA, 2, 2)
|
||||
expectSyncCounts(t, appB, 0, 2)
|
||||
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)
|
||||
}
|
||||
|
|
@ -85,6 +96,83 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -108,3 +196,25 @@ func expectText(t *testing.T, app *App, path, want string) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ var platformCapabilities = []string{
|
|||
"verstak/core/files/v1",
|
||||
"verstak/core/workbench/v1",
|
||||
"verstak/core/notifications/v1",
|
||||
"verstak/core/workspace/v1",
|
||||
}
|
||||
|
||||
// CorePlatformCapabilities returns a copy of the capabilities registered by
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ type Registry struct {
|
|||
statusBarItems []ContributionStatusBarItem
|
||||
openProviders []ContributionOpenProvider
|
||||
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.
|
||||
|
|
@ -42,6 +49,7 @@ const (
|
|||
PointStatusBar ContributionPointType = "statusBarItems"
|
||||
PointOpenProviders ContributionPointType = "openProviders"
|
||||
PointWorkspaceItems ContributionPointType = "workspaceItems"
|
||||
PointWorkspaceTree ContributionPointType = "workspaceTree"
|
||||
)
|
||||
|
||||
// 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.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
|
||||
r.workspaceTree = nil
|
||||
|
||||
for _, item := range c.Views {
|
||||
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 {
|
||||
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.
|
||||
|
|
@ -240,6 +252,9 @@ func (r *Registry) Unregister(pluginID string) {
|
|||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
|
||||
if r.workspaceTree != nil && r.workspaceTree.PluginID == pluginID {
|
||||
r.workspaceTree = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Getters — sorted for deterministic display.
|
||||
|
|
@ -367,6 +382,13 @@ func (r *Registry) WorkspaceItems() []ContributionWorkspaceItem {
|
|||
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 ─────────────────────────────────────────
|
||||
|
||||
func removeViews(items []ContributionView, pluginID string) []ContributionView {
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ func IsReservedPath(relativePath string) bool {
|
|||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
first := strings.Split(cleaned, "/")[0]
|
||||
return strings.EqualFold(first, ".verstak")
|
||||
return containsReservedSegment(cleaned)
|
||||
}
|
||||
|
||||
func normalizeRelativePath(input string, allowRoot bool) (string, error) {
|
||||
|
|
@ -67,8 +66,16 @@ func IsReservedPathNoNormalize(cleaned string) bool {
|
|||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
first := strings.Split(cleaned, "/")[0]
|
||||
return strings.EqualFold(first, ".verstak")
|
||||
return containsReservedSegment(cleaned)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func TestNormalizeRelativeFileRejectsUnsafePaths(t *testing.T) {
|
|||
".verstak/vault.json",
|
||||
"./.verstak",
|
||||
".verstak/trash",
|
||||
"Workspace/.verstak/workspace.json",
|
||||
"folder/../.verstak",
|
||||
".Verstak",
|
||||
}
|
||||
|
|
@ -70,6 +71,9 @@ func TestReservedPathPolicy(t *testing.T) {
|
|||
if IsReservedPath("Notes/.verstak.md") {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"os"
|
||||
|
|
@ -201,6 +202,85 @@ func (s *Service) WriteVaultFileBytes(relativePath string, dataBase64 string, op
|
|||
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 {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,18 @@ type Service struct {
|
|||
cancel chan struct{}
|
||||
done chan struct{}
|
||||
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.
|
||||
|
|
@ -133,22 +145,30 @@ func (s *Service) poll(root string) {
|
|||
s.mu.Lock()
|
||||
prev := s.current
|
||||
s.current = next
|
||||
callback := s.onChange
|
||||
s.mu.Unlock()
|
||||
changed := false
|
||||
for path, entry := range next {
|
||||
old, ok := prev[path]
|
||||
if !ok {
|
||||
s.publish(path, "external.create", entry.kind)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if entry.kind == entryFile && (entry.size != old.size || !entry.modTime.Equal(old.modTime)) {
|
||||
s.publish(path, "external.update", entry.kind)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for path, entry := range prev {
|
||||
if _, ok := next[path]; !ok {
|
||||
s.publish(path, "external.delete", entry.kind)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed && callback != nil {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publish(path, operation string, kind entryKind) {
|
||||
|
|
@ -217,8 +237,12 @@ func kindFromInfo(info fs.FileInfo) entryKind {
|
|||
}
|
||||
|
||||
func isReserved(rel string) bool {
|
||||
first := strings.Split(filepath.ToSlash(rel), "/")[0]
|
||||
return strings.EqualFold(first, ".verstak")
|
||||
for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
|
||||
if strings.EqualFold(segment, ".verstak") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func workspaceRoot(path string) string {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
t.Helper()
|
||||
select {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,12 @@ type Contributions struct {
|
|||
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
|
||||
OpenProviders []ContributionOpenProvider `json:"openProviders,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.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package sync
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -216,68 +218,147 @@ func (c *Client) Push(ops []Op) (*PushResponse, error) {
|
|||
// PullRequest is the payload for POST /sync/pull.
|
||||
type PullRequest struct {
|
||||
SinceSequence int `json:"since_sequence"`
|
||||
PageLimit int `json:"page_limit,omitempty"`
|
||||
}
|
||||
|
||||
// PullResponse is the response from POST /sync/pull.
|
||||
type PullResponse struct {
|
||||
ServerSequence int `json:"server_sequence"`
|
||||
PageLastSequence int `json:"page_last_sequence"`
|
||||
HasMore bool `json:"has_more"`
|
||||
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.
|
||||
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
|
||||
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// UploadBlob uploads a file to the server and returns its SHA-256.
|
||||
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
|
||||
// UploadBlob streams a local file through a multipart pipe. It keeps the
|
||||
// process memory bounded even when the file is many times larger than an
|
||||
// inline sync payload.
|
||||
func (c *Client) UploadBlob(localPath string) (BlobReference, error) {
|
||||
info, err := os.Stat(localPath)
|
||||
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 {
|
||||
return "", err
|
||||
_ = writer.CloseWithError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(fw, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
w.Close()
|
||||
writeDone <- err
|
||||
}()
|
||||
|
||||
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 {
|
||||
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())
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
_ = reader.Close()
|
||||
<-writeDone
|
||||
return BlobReference{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int `json:"size"`
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
writeErr := <-writeDone
|
||||
if writeErr != nil {
|
||||
return BlobReference{}, writeErr
|
||||
}
|
||||
return BlobReference{}, c.readErrorBody(resp, resp.StatusCode)
|
||||
}
|
||||
var result BlobReference
|
||||
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.
|
||||
func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
|
||||
func (c *Client) DownloadBlob(shaHex, destPath string) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -289,17 +370,65 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
tmpPath := out.Name()
|
||||
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
|
||||
}
|
||||
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 {
|
||||
|
|
@ -430,5 +559,11 @@ func (c *Client) readErrorBody(resp *http.Response, statusCode int) error {
|
|||
if strings.Contains(lower, "<html") || strings.Contains(lower, "<!doctype") {
|
||||
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"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -119,3 +124,95 @@ func TestPairDeviceSendsVaultID(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -15,6 +16,7 @@ const (
|
|||
EntityNote = "note"
|
||||
EntityFile = "file"
|
||||
EntityFolder = "folder"
|
||||
EntityWorkspace = "workspace"
|
||||
EntityAction = "action"
|
||||
EntityWorklog = "worklog"
|
||||
)
|
||||
|
|
@ -24,6 +26,9 @@ const (
|
|||
OpUpdate = "update"
|
||||
OpDelete = "delete"
|
||||
OpMove = "move"
|
||||
OpRename = "rename"
|
||||
OpTrash = "trash"
|
||||
OpRestore = "restore"
|
||||
)
|
||||
|
||||
// Op represents a sync operation.
|
||||
|
|
@ -50,6 +55,9 @@ type syncState struct {
|
|||
DeviceID string `json:"device_id"`
|
||||
LastPullSeq int `json:"last_pull_seq"`
|
||||
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.
|
||||
|
|
@ -81,6 +89,14 @@ func (s *Service) statePath() string {
|
|||
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 {
|
||||
return os.MkdirAll(s.syncDir(), 0o755)
|
||||
}
|
||||
|
|
@ -113,11 +129,33 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
|
|||
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()
|
||||
if err != nil {
|
||||
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)
|
||||
existing[op.OpID] = true
|
||||
}
|
||||
return s.saveOps(ops)
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +198,42 @@ func (s *Service) GetUnpushedOps() ([]Op, error) {
|
|||
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.
|
||||
func (s *Service) MarkPushed(opIDs []string) error {
|
||||
ops, err := s.loadOps()
|
||||
|
|
@ -244,6 +318,66 @@ func (s *Service) SetLastSyncAt(t string) error {
|
|||
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.
|
||||
func (s *Service) GetDeviceID() string {
|
||||
return s.deviceID
|
||||
|
|
@ -288,7 +422,7 @@ func (s *Service) saveOps(ops []Op) error {
|
|||
if err != nil {
|
||||
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) {
|
||||
|
|
@ -311,5 +445,42 @@ func (s *Service) saveState(st *syncState) error {
|
|||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -7,12 +7,16 @@ import (
|
|||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Project"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Test"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, ".git"))
|
||||
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) {
|
||||
if runtime.GOOS == "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) {
|
||||
vaultDir := newVaultDir(t)
|
||||
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)
|
||||
m := NewManager(vaultDir)
|
||||
|
||||
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"}
|
||||
for _, name := range names {
|
||||
if _, err := m.CreateWorkspace(name, ""); err == nil {
|
||||
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name)
|
||||
paths := []string{"", " ", `A\B`, "/abs", `C:\abs`, "..", "a/../b", "bad\x00name", ".verstak", ".Verstak", ".git"}
|
||||
for _, path := range paths {
|
||||
if _, err := m.CreateWorkspace(path, ""); err == nil {
|
||||
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid path error", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
|
||||
func TestCompatibilityTreeIncludesFoldersAndNestedWorkspaces(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
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"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
|
||||
|
||||
m := NewManager(vaultDir)
|
||||
if err := m.Load(); err != nil {
|
||||
|
|
@ -493,34 +640,43 @@ func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
|
|||
if len(tree.Nodes) != 2 {
|
||||
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 != "" {
|
||||
t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0])
|
||||
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" {
|
||||
t.Fatalf("first compatibility node = %+v, want workspace node", tree.Nodes[0])
|
||||
}
|
||||
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" {
|
||||
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)
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Project"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Test"))
|
||||
|
||||
m := NewManager(vaultDir)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// Move workspace into Test folder (which is a plain folder without marker)
|
||||
err := m.MoveNode("Project", "Test")
|
||||
if err == nil || !strings.Contains(err.Error(), "top-level only") {
|
||||
t.Fatalf("MoveNode error = %v, want top-level only", err)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveNode error = %v, want success", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr)
|
||||
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); statErr != nil {
|
||||
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 {
|
||||
t.Helper()
|
||||
vaultDir := filepath.Join(t.TempDir(), "vault")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
package tray
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultIconUsesPlatformResource(t *testing.T) {
|
||||
icon := DefaultIcon()
|
||||
|
|
@ -15,4 +21,23 @@ func TestDefaultIconUsesPlatformResource(t *testing.T) {
|
|||
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")
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 4.5 KiB |
10
main.go
10
main.go
|
|
@ -99,13 +99,9 @@ func main() {
|
|||
}
|
||||
log.Printf("[main] registered vault capability")
|
||||
|
||||
// Register workspace capability (only when vault is open and workspace initialized)
|
||||
if workspaceMgr != nil && workspaceMgr.IsInitialized() {
|
||||
if err := capRegistry.Register(corePluginID, []string{"verstak/core/workspace/v1"}); err != nil {
|
||||
log.Fatalf("[main] failed to register workspace capability: %v", err)
|
||||
}
|
||||
log.Printf("[main] registered workspace capability")
|
||||
}
|
||||
// Workspace capability is registered as a platform capability at startup;
|
||||
// explicit registration here is a no-op guard for late initialisation.
|
||||
_ = capRegistry.Register(corePluginID, []string{"verstak/core/workspace/v1"})
|
||||
|
||||
// ─── Plugin Discovery ───────────────────────────────────
|
||||
discoveryDirs := plugin.DefaultDiscoveryDirs()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
drag‑and‑drop, 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` добавляет иконки папок, цвета,
|
||||
drag‑and‑drop, полный 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.
|
||||
|
|
@ -35,6 +35,8 @@ fi
|
|||
cd "$ROOT"
|
||||
echo "=== verstak desktop Windows amd64 build ==="
|
||||
|
||||
"$ROOT/scripts/generate-brand-icons.sh"
|
||||
|
||||
if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
|
||||
if [[ -f "$ROOT/frontend/package-lock.json" ]]; then
|
||||
(cd "$ROOT/frontend" && npm ci --no-audit --no-fund)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|||
echo "=== verstak-desktop build ==="
|
||||
echo ""
|
||||
|
||||
"$ROOT/scripts/generate-brand-icons.sh"
|
||||
|
||||
# ── Dependency checks ──
|
||||
echo "[deps]"
|
||||
if ! command -v go &>/dev/null; then
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -30,7 +30,10 @@ fi
|
|||
) >"$LOG_FILE" 2>&1 &
|
||||
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
|
||||
break
|
||||
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)"
|
||||
sqlite3 "$DATA_DIR/server.db" "
|
||||
INSERT INTO server_devices (id, name, api_key, last_seen, created_at)
|
||||
VALUES ('smoke-device-a', 'Smoke Device A', 'smoke-key-a', '$NOW', '$NOW');
|
||||
INSERT INTO server_devices (id, name, api_key, last_seen, created_at)
|
||||
VALUES ('smoke-device-b', 'Smoke Device B', 'smoke-key-b', '$NOW', '$NOW');
|
||||
INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at)
|
||||
VALUES ('smoke-user', 'smoke-user', 'smoke@example.test', 'unused', 1, '$NOW');
|
||||
INSERT INTO server_devices (id, name, api_key, legacy_api_key, user_id, vault_id, last_seen, created_at)
|
||||
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');
|
||||
"
|
||||
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -53,6 +53,9 @@ grep -Fq 'LinkId=2124701' "$ROOT/README.md"
|
|||
grep -Fq 'WebView2 Runtime' "$ROOT/README.md"
|
||||
grep -Fq 'package-deb.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"
|
||||
git -C "$ROOT" check-ignore -q verstak-desktop-res.syso
|
||||
grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ OUTPUT=$(cd "$ROOT" && go test -count=1 -v ./... 2>&1) || GO_TEST_STATUS=$?
|
|||
echo "$OUTPUT" | grep -E '(FAIL|PASS|---)' || true
|
||||
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
|
||||
(cd "$ROOT" && node frontend/tests/wails-bindings-test.mjs) || WAILS_BINDINGS_STATUS=$?
|
||||
report "Wails notification bindings" "$WAILS_BINDINGS_STATUS"
|
||||
|
|
|
|||
Loading…
Reference in New Issue