Author SHA1 Message Date
mirivlad d1f9157c05 docs: publish bilingual product README 2026-07-14 03:19:27 +08:00
mirivlad a00207d86e fix: keep command-line debug session-only 2026-07-14 03:19:06 +08:00
mirivlad fe912c9659 build: refresh notification Wails bindings 2026-07-14 02:59:38 +08:00
mirivlad 8e3dedf0c5 build: package Linux tray runtime 2026-07-14 02:55:52 +08:00
mirivlad d8a4213569 feat: keep Verstak running in the system tray 2026-07-14 02:54:30 +08:00
mirivlad e33c63d3ad feat: run scheduled native notifications 2026-07-14 02:42:50 +08:00
mirivlad 96dd499e0b feat: expose scheduled notifications to plugins 2026-07-14 02:37:28 +08:00
mirivlad 67753927cb feat: add persisted notification scheduler 2026-07-14 02:34:00 +08:00
mirivlad 55edaa0209 docs: make tray icons reproducible 2026-07-14 02:31:12 +08:00
mirivlad 4d6a8bd8cd docs: plan notifications tray and public readme 2026-07-14 02:29:26 +08:00
mirivlad 9fc0ff764c docs: specify native notifications and tray 2026-07-14 02:23:15 +08:00
mirivlad eb5d6b1f06 build: ignore generated Windows resources 2026-07-14 02:14:10 +08:00
mirivlad 030f66d806 build: publish portable desktop releases 2026-07-14 01:58:42 +08:00
mirivlad da76b89856 build: use system WebView2 for Windows archive 2026-07-13 22:53:03 +08:00
mirivlad 20952e8477 build: add portable desktop packages 2026-07-13 22:29:58 +08:00
mirivlad f4e911c14a build: add Windows desktop test bundle 2026-07-13 21:46:48 +08:00
mirivlad 47a8d565e8 build: publish desktop releases to GitHub 2026-07-13 05:01:34 +08:00
mirivlad 581e6b27d8 docs: document public alpha setup 2026-07-13 03:57:58 +08:00
mirivlad ca5290015a build: guard desktop release prerequisites 2026-07-12 21:38:24 +08:00
mirivlad ad0c00bade docs: prepare desktop alpha release 2026-07-12 21:21:45 +08:00
mirivlad 4db5a7a132 feat: show plugin diagnostics only in debug mode 2026-07-12 21:18:09 +08:00
mirivlad c567b229cc feat: scope overview to durable activity data 2026-07-12 21:11:53 +08:00
mirivlad 5a249dcf24 feat: persist handled activity session watermarks 2026-07-12 20:48:57 +08:00
mirivlad 6b556c59d9 feat: scope activity and browser links by durable workspace 2026-07-12 20:39:03 +08:00
mirivlad fca9fff4b4 feat: retain archived browser inbox captures 2026-07-12 17:43:12 +08:00
mirivlad bcf49ed32c feat: persist passive browser activity batches 2026-07-12 17:29:07 +08:00
mirivlad 39052ffe39 feat: add canonical hostname normalization 2026-07-12 17:16:53 +08:00
mirivlad e1a6b2af64 feat: publish workspace trash lifecycle 2026-07-12 17:11:00 +08:00
mirivlad 5280d245ee feat: add durable workspace identities 2026-07-12 17:06:34 +08:00
mirivlad 477b372110 fix: persist browser captures atomically 2026-07-11 23:30:38 +08:00
mirivlad c129c1a2ac fix: preserve plugin contribution label contract 2026-07-11 12:56:17 +08:00
mirivlad 7f0cc308c2 feat: add Russian desktop localization 2026-07-11 12:50:00 +08:00
mirivlad 8b1151e03a feat: load declared plugin locale catalogs 2026-07-11 02:02:47 +08:00
mirivlad 15f1f413d0 feat: persist desktop language preference 2026-07-11 02:00:10 +08:00
84 changed files with 7789 additions and 565 deletions
+3 -1
View File
@@ -1,8 +1,10 @@
# .gitignore
frontend/node_modules/
frontend/dist/
build/bin/verstak-desktop
build/
verstak-desktop-res.syso
smoke-platform
plugins/
vendor/
frontend/e2e-results/
release/
+17
View File
@@ -0,0 +1,17 @@
Verstak Desktop
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later
+340 -2
View File
@@ -1,3 +1,341 @@
# verstak-desktop
<div align="center">
Verstak Core Platform + UI Shell — plugin runtime, capability registry, vault API, event bus, sync client
<img src="packaging/linux/verstak.svg" width="112" alt="Verstak logo">
# Verstak
### Keep the context of your work together — locally.
Files, notes, links, browser captures, activity and work history
in one extensible local-first workspace.
**English** · [Русский](README.ru.md)
[![Release](https://img.shields.io/github/v/release/mirivlad/verstak?include_prereleases\&label=release)](https://github.com/mirivlad/verstak/releases)
![Status](https://img.shields.io/badge/status-alpha-orange)
![Platforms](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)
[![License](https://img.shields.io/github/license/mirivlad/verstak)](LICENSE)
[Download](https://github.com/mirivlad/verstak/releases/latest) ·
[Documentation](https://github.com/mirivlad/verstak-docs) ·
[Report an issue](https://github.com/mirivlad/verstak/issues)
</div>
> [!WARNING]
> Verstak is currently **alpha software**. APIs, storage formats and packaging may change before the first stable release. Use a test vault and keep backups of important data.
## What is Verstak?
Verstak is a local-first workbench that keeps the context around your work in one place.
A **workspace** can represent almost anything:
* a software project;
* a client or customer;
* a server or infrastructure site;
* a device under repair;
* an article or research topic;
* a training course;
* a personal long-term project.
Normally, information about such work is scattered across directories, notes, browser tabs, task managers, password managers, terminal history and memory.
Verstak brings it together in a local **vault** that remains under your control.
No account, cloud service or sync server is required for local use.
## Verstak in use
| Overview: return to a workspace and see its recent work, captures and items that need attention. | Files: manage ordinary folders and documents inside a workspace. |
| --- | --- |
| ![Verstak Overview in a test vault](docs/screenshots/overview.png) | ![Verstak workspace files in a test vault](docs/screenshots/workspace-files.png) |
| Notes: keep Markdown notes next to the workspace they describe. | Journal: record a completed session and retain its context. |
| --- | --- |
| ![Verstak notes in a test vault](docs/screenshots/notes.png) | ![Verstak journal in a test vault](docs/screenshots/journal.png) |
## Main features
| Feature | Description |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| **Workspaces** | Organize files, notes and activity around durable projects, clients and other areas of work. |
| **Files** | Browse and manage ordinary files stored inside your vault. |
| **Notes** | Create Markdown notes, overview pages and links between related information. |
| **Overview** | Quickly return to recent work and see what may need attention. |
| **Browser Inbox** | Send pages, links, selections and files from a browser into Verstak. |
| **Activity and Journal** | Reconstruct work sessions and turn selected activity into journal entries. |
| **Todo** | Keep optional task lists inside individual workspaces and across the whole vault. |
| **Search** | Search across notes, files and supported plugin data. |
| **Trash** | Restore deleted items or remove them permanently from one central location. |
| **Secrets** | Keep credentials and access information connected to the relevant workspace. |
| **Templates** | Create repeatable workspace structures for common types of work. |
| **Plugins** | Add, replace or disable tools without turning Verstak into one rigid monolithic application. |
| **Optional sync** | Synchronize vaults between devices using a self-hosted Verstak Sync Server. |
## Download and install
Download the latest build from the
[GitHub Releases page](https://github.com/mirivlad/verstak/releases/latest).
Release packages already include the matching official plugins.
| System | Download | Installation |
| --------------------------------- | ----------------------------------------- | --------------------------------------------- |
| Debian 13 / Ubuntu 24.04 or newer | `verstak_<version>_amd64.deb` | Install through APT. |
| Other x86_64 Linux distributions | `verstak-linux-x86_64-<version>.AppImage` | Make the file executable and run it. |
| Windows 10/11 x64 | `verstak-windows-amd64-<version>.zip` | Extract the archive and launch `Verstak.cmd`. |
### Debian and Ubuntu
```bash
sudo apt install ./verstak_*_amd64.deb
```
Launch Verstak from the application menu or run:
```bash
verstak
```
### AppImage
```bash
chmod +x verstak-linux-x86_64-*.AppImage
./verstak-linux-x86_64-*.AppImage
```
On a system without FUSE support:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
```
### Windows portable version
1. Download `verstak-windows-amd64-<version>.zip`.
2. Extract it to a local directory.
3. Run `Verstak.cmd`.
Do not run Verstak directly from inside the ZIP archive or from a network share.
Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows 11 and most current Windows 10 installations. If Verstak does not start, install the
[Microsoft WebView2 Runtime x64](https://go.microsoft.com/fwlink/p/?LinkId=2124701).
## Background and tray mode
Closing the main window keeps Verstak running in the system tray. Use **Show Verstak** to bring it back and **Quit** to exit the application completely. This lets scheduled Todo reminders continue while the window is hidden.
### Verify a download
Each release includes a `SHA256SUMS` file.
On Linux:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## First start
### 1. Create or open a vault
A **vault** is the root directory where Verstak stores your work.
Choose a writable local directory. For example:
```text
Documents/
└── Verstak/
```
Your files and notes remain ordinary files that can be accessed without Verstak.
### 2. Create your first workspace
Inside the vault, create a workspace for a project, client, server, device or another area of work.
For example:
```text
Home server
Customer Alpha
Verstak development
3D printer
Training course
```
### 3. Add context
Open the workspace and add the information needed to return to it later:
* notes and decisions;
* documents and source files;
* useful links;
* access information;
* journal entries;
* tasks;
* materials captured from the browser.
### 4. Return through Overview
The Overview screen shows recent changes, unfinished captures, activity and other useful entry points back into your work.
## How Verstak stores data
Verstak follows several principles:
* your vault is local and works without an account or internet connection;
* files and notes remain readable outside the application;
* synchronization is optional and is not the source of truth;
* the user should understand where data is stored and what happens to it;
* application tools are provided by plugins rather than being permanently embedded into the core.
The desktop application loads plugins from a `plugins/` directory located beside the executable.
## Browser integration
The optional browser extension can send the following into Browser Inbox:
* the current page;
* selected text;
* a link;
* a downloaded or selected file.
Repository:
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
To connect the extension:
1. Open Browser Inbox settings in Verstak.
2. Copy the Receiver URL and Pairing Token.
3. Paste them into the extension settings.
4. Save the settings and send a test page.
Passive domain activity tracking is disabled by default. When enabled, it sends bounded time totals by normalized domain. It does not send page contents, keystrokes or full browsing history.
## Optional synchronization
Local use does not require a server.
For synchronization between devices, deploy the optional self-hosted
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
Each vault is connected separately. The local vault remains the primary copy of your data.
## Build from source
### Requirements
* Go 1.24 or newer;
* Node.js 20 or newer with npm;
* Python 3;
* Git;
* Wails v2 build dependencies;
* WebKitGTK development packages on Linux.
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
See the
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/)
for distribution-specific dependencies.
### Clone the repositories
The desktop, SDK and official plugin repositories must be cloned as sibling directories:
```text
verstak-workspace/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-workspace
cd verstak-workspace
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
git clone https://github.com/mirivlad/verstak-official-plugins.git
```
### Build
```bash
cd verstak-sdk
./scripts/build.sh
cd ../verstak-official-plugins
./scripts/build.sh
cd ../verstak
./scripts/install-dev-plugins.sh
./scripts/build.sh
```
The resulting application will be located at:
```bash
./build/bin/verstak-desktop
```
Run it with additional diagnostics:
```bash
./build/bin/verstak-desktop --debug
```
## Build release packages locally
These commands create local artifacts in `release/`. They do not publish a GitHub Release.
### Debian package
```bash
./scripts/package-deb.sh v0.1.0-alpha.1
```
### AppImage
```bash
./scripts/package-appimage.sh v0.1.0-alpha.1
```
### Windows portable ZIP
The Windows archive can be cross-compiled on Linux using MinGW:
```bash
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1
```
## Project repositories
| Repository | Purpose |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------ |
| [verstak](https://github.com/mirivlad/verstak) | Desktop application, core platform and UI shell |
| [verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Official Files, Notes, Journal, Todo and other plugins |
| [verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript SDK, schemas and plugin contracts |
| [verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Browser capture and optional activity integration |
| [verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Optional self-hosted synchronization server |
| [verstak-docs](https://github.com/mirivlad/verstak-docs) | Product and platform architecture documentation |
Compatible components should be built from the same release line.
## Development status
Verstak is under active development.
The current alpha is intended for testing, feedback and experimentation. Backward compatibility is not guaranteed until the first stable release.
Bug reports and feature discussions are welcome in
[GitHub Issues](https://github.com/mirivlad/verstak/issues).
## License
Copyright © 2026 Verstak contributors.
Verstak is licensed under the
[GNU Affero General Public License v3.0 or later](LICENSE).
+340
View File
@@ -0,0 +1,340 @@
<div align="center">
<img src="packaging/linux/verstak.svg" width="112" alt="Логотип Верстака">
# Верстак
### Рабочий контекст остаётся рядом — и хранится локально.
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
в одном расширяемом рабочем пространстве.
[English](README.md) · **Русский**
[![Релиз](https://img.shields.io/github/v/release/mirivlad/verstak?include_prereleases\&label=release)](https://github.com/mirivlad/verstak/releases)
![Статус](https://img.shields.io/badge/status-alpha-orange)
![Платформы](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)
[![Лицензия](https://img.shields.io/github/license/mirivlad/verstak)](LICENSE)
[Скачать](https://github.com/mirivlad/verstak/releases/latest) ·
[Документация](https://github.com/mirivlad/verstak-docs) ·
[Сообщить о проблеме](https://github.com/mirivlad/verstak/issues)
</div>
> [!WARNING]
> Верстак пока находится на стадии **alpha**. До первого стабильного выпуска могут меняться API, форматы хранения и состав пакетов. Для знакомства используйте тестовый vault и сохраняйте резервные копии важных данных.
## Что такое Верстак
Верстак — это local-first рабочая среда, которая удерживает в одном месте контекст вокруг ваших дел.
**Делом** может быть что угодно:
* программный проект;
* клиент или заказчик;
* сервер или площадка;
* ремонтируемое устройство;
* статья или исследование;
* учебный курс;
* личный долгосрочный проект.
Обычно информация о таком деле расползается по папкам, заметкам, браузерным вкладкам, менеджерам задач, хранилищам паролей, истории терминала и памяти.
Верстак собирает её в один локальный **vault**, который остаётся под вашим контролем.
Для локальной работы не нужны учётная запись, облачный сервис или собственный сервер.
## Верстак в работе
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
| --- | --- |
| ![Экран «Обзор» Верстака в тестовом vault](docs/screenshots/overview.png) | ![Файлы дела в тестовом vault Верстака](docs/screenshots/workspace-files.png) |
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
| --- | --- |
| ![Заметки Верстака в тестовом vault](docs/screenshots/notes.png) | ![Журнал Верстака в тестовом vault](docs/screenshots/journal.png) |
## Основные возможности
| Возможность | Что она даёт |
| ------------------------ | ---------------------------------------------------------------------------------------------- |
| **Дела** | Объединяют файлы, заметки и историю работы вокруг проектов, клиентов и других направлений. |
| **Файлы** | Позволяют работать с обычными файлами, расположенными внутри vault. |
| **Заметки** | Markdown-заметки, обзорные страницы и связи между материалами. |
| **Обзор** | Помогает быстро вернуться к недавней работе и увидеть то, что требует внимания. |
| **Входящие из браузера** | Принимают страницы, ссылки, выделенный текст и файлы из браузера. |
| **Активность и журнал** | Помогают восстановить рабочие сессии и превратить выбранную активность в записи журнала. |
| **Задачи** | Необязательные списки задач внутри отдельных дел и во всём vault. |
| **Поиск** | Ищет по заметкам, файлам и данным поддерживаемых плагинов. |
| **Корзина** | Позволяет восстановить удалённое или окончательно удалить его из одного общего раздела. |
| **Секреты** | Хранит доступы и учётные данные рядом с делом, к которому они относятся. |
| **Шаблоны** | Создают повторяемую структуру для однотипных дел. |
| **Плагины** | Позволяют добавлять, заменять и отключать инструменты, не превращая Верстак в жёсткий монолит. |
| **Синхронизация** | Необязательно синхронизирует vault между устройствами через собственный сервер. |
## Скачивание и установка
Готовые сборки находятся на странице
[GitHub Releases](https://github.com/mirivlad/verstak/releases/latest).
В релизные пакеты уже входят совместимые версии официальных плагинов. Устанавливать их отдельно не требуется.
| Система | Какой файл скачать | Как установить |
| -------------------------------- | ---------------------------------------- | -------------------------------------------- |
| Debian 13 / Ubuntu 24.04 и новее | `verstak_<версия>_amd64.deb` | Установить через APT. |
| Другие дистрибутивы Linux x86_64 | `verstak-linux-x86_64-<версия>.AppImage` | Сделать файл исполняемым и запустить. |
| Windows 10/11 x64 | `verstak-windows-amd64-<версия>.zip` | Распаковать архив и запустить `Verstak.cmd`. |
### Debian и Ubuntu
```bash
sudo apt install ./verstak_*_amd64.deb
```
После установки запустите Верстак из меню приложений или командой:
```bash
verstak
```
### AppImage
```bash
chmod +x verstak-linux-x86_64-*.AppImage
./verstak-linux-x86_64-*.AppImage
```
Если в системе нет поддержки FUSE:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
```
### Портативная версия для Windows
1. Скачайте `verstak-windows-amd64-<версия>.zip`.
2. Распакуйте архив в локальную папку.
3. Запустите `Verstak.cmd`.
Не запускайте Верстак прямо из ZIP-архива или с сетевого диска.
Верстак использует Microsoft WebView2 Runtime. Он уже присутствует в Windows 11 и большинстве актуальных установок Windows 10. Если приложение не запускается, установите
[Microsoft WebView2 Runtime x64](https://go.microsoft.com/fwlink/p/?LinkId=2124701).
## Фоновая работа и трей
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
### Проверка скачанного файла
В каждый релиз входит файл `SHA256SUMS`.
Проверка в Linux:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## Первый запуск
### 1. Создайте или откройте vault
**Vault** — это корневая папка, в которой Верстак хранит ваши дела.
Выберите доступный для записи локальный каталог. Например:
```text
Документы/
└── Верстак/
```
Файлы и заметки остаются обычными файлами. Их можно открыть и без Верстака.
### 2. Создайте первое дело
Внутри vault создайте дело для проекта, клиента, сервера, устройства или другого направления работы.
Например:
```text
Домашний сервер
Заказчик Альфа
Разработка Верстака
3D-принтер
Учебный курс
```
### 3. Соберите контекст
Добавьте в дело всё, что поможет вернуться к нему через неделю, месяц или год:
* заметки и принятые решения;
* документы и исходные файлы;
* полезные ссылки;
* данные для доступа;
* записи журнала;
* задачи;
* материалы, отправленные из браузера.
### 4. Возвращайтесь через Обзор
Экран «Обзор» показывает недавние изменения, необработанные входящие, активность и другие подходящие точки для продолжения работы.
## Как Верстак хранит данные
Верстак придерживается нескольких принципов:
* vault хранится локально и доступен без учётной записи и интернета;
* файлы и заметки остаются читаемыми вне приложения;
* синхронизация является дополнением, а не источником истины;
* пользователь должен понимать, где находятся его данные и что с ними происходит;
* инструменты реализуются плагинами, а не навсегда встраиваются в ядро.
Настольное приложение загружает плагины из каталога `plugins/`, расположенного рядом с исполняемым файлом.
## Интеграция с браузером
Необязательное расширение позволяет отправлять во «Входящие из браузера»:
* текущую страницу;
* выделенный текст;
* ссылку;
* выбранный или скачанный файл.
Репозиторий расширения:
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
Чтобы подключить расширение:
1. Откройте настройки «Входящих из браузера» в Верстаке.
2. Скопируйте адрес приёмника и токен сопряжения.
3. Вставьте их в настройки расширения.
4. Сохраните настройки и отправьте тестовую страницу.
Пассивный учёт активности по доменам по умолчанию отключён. После включения расширение передаёт только ограниченные интервалы времени для нормализованных доменов. Оно не отправляет содержимое страниц, нажатия клавиш или полную историю посещений.
## Необязательная синхронизация
Для локального использования сервер не нужен.
Для синхронизации между устройствами можно развернуть собственный
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
Каждый vault подключается отдельно. Основной копией данных остаётся локальный vault.
## Сборка из исходного кода
### Требования
* Go 1.24 или новее;
* Node.js 20 или новее и npm;
* Python 3;
* Git;
* зависимости для сборки Wails v2;
* пакеты разработки WebKitGTK в Linux.
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
Зависимости для конкретного дистрибутива перечислены в
[документации Wails](https://wails.io/docs/gettingstarted/installation/).
### Клонирование репозиториев
Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
```text
verstak-workspace/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-workspace
cd verstak-workspace
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
git clone https://github.com/mirivlad/verstak-official-plugins.git
```
### Сборка
```bash
cd verstak-sdk
./scripts/build.sh
cd ../verstak-official-plugins
./scripts/build.sh
cd ../verstak
./scripts/install-dev-plugins.sh
./scripts/build.sh
```
Готовое приложение появится по адресу:
```bash
./build/bin/verstak-desktop
```
Запуск с дополнительной диагностикой:
```bash
./build/bin/verstak-desktop --debug
```
## Локальная сборка релизных пакетов
Следующие команды создают пакеты в каталоге `release/`, но не публикуют GitHub Release.
### Пакет Debian
```bash
./scripts/package-deb.sh v0.1.0-alpha.1
```
### AppImage
```bash
./scripts/package-appimage.sh v0.1.0-alpha.1
```
### Портативный ZIP для Windows
Windows-версию можно собрать в Linux при помощи MinGW:
```bash
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1
```
## Репозитории проекта
| Репозиторий | Назначение |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [verstak](https://github.com/mirivlad/verstak) | Настольное приложение, ядро платформы и оболочка интерфейса |
| [verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Официальные плагины файлов, заметок, журнала, задач и других инструментов |
| [verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript SDK, схемы и контракты плагинов |
| [verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Отправка материалов из браузера и необязательный учёт активности |
| [verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Необязательный собственный сервер синхронизации |
| [verstak-docs](https://github.com/mirivlad/verstak-docs) | Продуктовая и архитектурная документация |
Совместимые компоненты следует собирать из одной релизной линии.
## Состояние разработки
Верстак активно развивается.
Текущая alpha предназначена для тестирования, обратной связи и экспериментов. До первого стабильного выпуска обратная совместимость не гарантируется.
Сообщения об ошибках и предложения принимаются в
[GitHub Issues](https://github.com/mirivlad/verstak/issues).
## Лицензия
Copyright © 2026 Verstak contributors.
Верстак распространяется на условиях
[GNU Affero General Public License v3.0 или новее](LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,341 @@
# Native Notifications, Tray, and Public README Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver native Todo reminders and Windows/Linux tray operation, then publish updated alpha artifacts and public README documentation with real application screenshots.
**Architecture:** Desktop core stores and delivers plugin-owned notification schedules and controls the tray lifecycle. Todo uses the public plugin API to replace its desired reminders. A small core tray adapter keeps the Wails process alive after window close. Documentation uses the supplied English and Russian README sources plus screenshots from an actual test vault.
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `getlantern/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
## Global constraints
- Support Windows and Linux only; no background daemon after an explicit Quit.
- Closing a window hides it; only tray **Quit** ends the process.
- `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
- Plugins cannot receive Wails runtime access.
- Keep Wails generated bindings out of commits.
- Commit each independent change and immediately push it to GitHub and `mirror`.
---
### Task 1: Persisted notification scheduler
**Files:**
- Create: `internal/core/notifications/manager.go`
- Create: `internal/core/notifications/store.go`
- Create: `internal/core/notifications/manager_test.go`
**Interfaces:**
- `type Request struct { ID, DueAt, Title, Body string }`
- `type Item struct { PluginID, ID, DueAt, Title, Body, SentForDueAt string }`
- `type Sender interface { Send(context.Context, Item) error }`
- `Manager.Replace(pluginID string, requests []Request) error`, `Clear(pluginID string) error`, `Start(context.Context)`, and `Stop()`.
- Persistent state: `<vault>/.verstak/notifications/schedules.json`.
- [ ] **Step 1: Add failing scheduler tests**
```go
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
sender := &fakeSender{err: errors.New("unavailable")}
m := newTestManager(t, sender, time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))
requireNoError(t, m.Replace("verstak.todo", []Request{{ID: "todo-1", DueAt: "2026-07-14T11:00:00Z", Title: "Reminder"}}))
m.Tick(context.Background())
if sender.calls != 1 || m.Items()[0].SentForDueAt != "" { t.Fatal("failed send was acknowledged") }
sender.err = nil
m.Tick(context.Background())
m.Tick(context.Background())
if sender.calls != 2 || m.Items()[0].SentForDueAt != "2026-07-14T11:00:00Z" { t.Fatal("delivery was not exactly once") }
}
```
Also test stale records are removed by `Replace`, changing `DueAt` rearms a record, state reloads from disk, and a past-due unsent item is sent once.
- [ ] **Step 2: Confirm red**
Run: `GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications -count=1`
Expected: fails because the package does not exist.
- [ ] **Step 3: Implement the minimal manager**
Use one mutex, injected clock/sender, an atomic same-directory temp-file rename, and a 30-second ticker. Preserve `SentForDueAt` only if the requested due time is unchanged. Validate non-empty unique IDs, RFC3339 UTC dates, at most 500 requests, and bounded text. Mark sent only after `Sender.Send` succeeds.
- [ ] **Step 4: Verify and commit**
Run:
```bash
gofmt -w internal/core/notifications/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications ./internal/core/... -count=1
git diff --check
```
Commit/push:
```bash
git add internal/core/notifications
git commit -m "feat: add persisted notification scheduler"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 2: Permission-gated core/plugin notification API
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `internal/core/permissions/registry.go`
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/tests/plugin-api-contributions-test.mjs`
**Interfaces:**
- Core capability: `verstak/core/notifications/v1`.
- Permission: `notifications.schedule`, non-dangerous.
- Bound methods: `ReplacePluginNotifications(pluginID string, items []notifications.Request) string` and `ClearPluginNotifications(pluginID string) string`.
- Plugin methods: `api.notifications.replace(items)` and `api.notifications.clear()`.
- [ ] **Step 1: Add failing backend and host-API tests**
```go
func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) {
app, scheduler := newNotificationTestApp(t, manifestWithoutNotificationPermission)
if got := app.ReplacePluginNotifications("example", []notifications.Request{{ID: "r", DueAt: "2026-07-14T10:00:00Z", Title: "R"}}); got == "" {
t.Fatal("missing permission was accepted")
}
if scheduler.replaceCalls != 0 { t.Fatalf("replace calls = %d", scheduler.replaceCalls) }
}
```
The JS test mocks `App.ReplacePluginNotifications`, calls `api.notifications.replace`, and asserts both plugin ID forwarding and a namespaced rejection for a non-empty backend error.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestReplacePluginNotificationsRequiresCapabilityAndPermission -count=1
node frontend/tests/plugin-api-contributions-test.mjs
```
Expected: missing API methods.
- [ ] **Step 3: Implement guarded bridge**
Register the capability in `main.go`, construct the scheduler, and guard bound calls with both existing access helpers before forwarding. Add `notifications.schedule` to the registry. Add the public host methods using `callBackendErrorString`; do not expose `window.runtime` to plugins.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
node frontend/tests/plugin-api-contributions-test.mjs
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go frontend/src/lib/plugin-host/VerstakPluginAPI.js frontend/tests/plugin-api-contributions-test.mjs
git commit -m "feat: expose scheduled notifications to plugins"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 3: Todo reminder synchronization and Wails lifecycle
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `../verstak-official-plugins/plugins/todo/plugin.json`
- Modify: `../verstak-official-plugins/plugins/todo/frontend/src/index.js`
- Modify: `../verstak-official-plugins/plugins/todo/locales/en.json`
- Modify: `../verstak-official-plugins/plugins/todo/locales/ru.json`
- Modify: `../verstak-official-plugins/scripts/smoke-todo-plugin.js`
**Interfaces:**
- `App.DomReady(ctx)` initializes Wails notifications before starting the scheduler.
- `App.Shutdown(ctx)` stops it before Wails cleanup.
- Todo replaces the complete reminder list only after Todo persistence succeeds and after Todo data loads.
- [ ] **Step 1: Add failing lifecycle and Todo smoke tests**
The App test uses function variables for Wails calls and proves initialize → start and stop → cleanup order. The Todo smoke mock records `notifications.replace`; it asserts one open reminder request with the Todo ID and a later empty replacement after completion or deletion.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run 'TestDomReadyInitializesNotificationsBeforeScheduler|TestShutdownStopsNotifications' -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js
```
Expected: lifecycle methods and schedule calls are absent.
- [ ] **Step 3: Implement lifecycle and Todo desired state**
Use `runtime.InitializeNotifications`, `runtime.SendNotification`, and `runtime.CleanupNotifications` only in core. Register `OnDomReady`/ `OnShutdown` in Wails options. Todo maps open valid `reminderAt` values to ISO UTC using `new Date(todo.reminderAt).toISOString()`, localizes title/body, and calls `api.notifications.replace`. A scheduling failure reports status but never rolls back already-saved Todo data.
- [ ] **Step 4: Verify and commit both repositories**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js && ./scripts/check.sh
```
```bash
cd ../verstak-desktop
git add main.go internal/api/app.go internal/api/app_test.go
git commit -m "feat: start native notifications with desktop app"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
cd ../verstak-official-plugins
git add plugins/todo scripts/smoke-todo-plugin.js
git commit -m "feat: schedule native Todo reminders"
git push https://github.com/mirivlad/verstak-official-plugins.git main
git push mirror main
```
### Task 4: Windows/Linux tray, close policy, and single instance
**Files:**
- Create: `internal/shell/tray/controller.go`
- Create: `internal/shell/tray/controller_test.go`
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `go.mod`
- Modify: `go.sum`
**Interfaces:**
- `tray.Start(icon []byte, actions Actions) error`, with `Actions.Show` and `Actions.Quit`.
- `App.BeforeClose(ctx) bool` hides and returns true unless `App.Quit()` made shutdown explicit.
- `App.ShowWindow()` reveals the existing window.
- [ ] **Step 1: Add failing close-policy and tray action tests**
```go
func TestBeforeCloseHidesWindowUntilExplicitQuit(t *testing.T) {
app, window := newWindowTestApp(t)
if prevent := app.BeforeClose(context.Background()); !prevent || window.hideCalls != 1 { t.Fatal("ordinary close must hide") }
app.Quit()
if prevent := app.BeforeClose(context.Background()); prevent { t.Fatal("explicit quit was prevented") }
}
```
Use a fake tray adapter to assert exactly two labels, **Show Verstak** and **Quit**, and one callback invocation per click.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestBeforeCloseHidesWindowUntilExplicitQuit -count=1
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray -count=1
```
Expected: absent packages/methods.
- [ ] **Step 3: Implement adapter and Wails wiring**
Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonblocking `systray.Register`, sets compact source-controlled PNG bytes derived from the existing tracked logo, and starts goroutines for the two click channels. `main.go` registers `OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls `app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/shell/tray/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
./scripts/build.sh
ldd build/bin/verstak-desktop | grep -E 'ayatana-appindicator|appindicator'
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/shell/tray go.mod go.sum
git commit -m "feat: keep desktop app in system tray"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 5: Packaging and public README screenshots
**Files:**
- Modify: `packaging/deb/control`
- Modify: `scripts/build.sh`
- Modify: `scripts/package-appimage.sh`
- Modify: `scripts/test-package-formats.sh`
- Modify: `README.md`
- Create: `README.ru.md`
- Create: `docs/screenshots/overview.png`
- Create: `docs/screenshots/workspace-files-notes.png`
- Create: `docs/screenshots/activity-journal.png`
- [ ] **Step 1: Add failing package-contract checks**
Add assertions for `libayatana-appindicator3-dev` in the Linux build guidance, `libayatana-appindicator3-1` in Debian dependencies, and `ayatana-appindicator` in the AppImage packing verification.
- [ ] **Step 2: Confirm red**
Run: `./scripts/test-package-formats.sh`
Expected: the first tray dependency assertion fails.
- [ ] **Step 3: Implement portable package support**
Declare the Debian runtime dependency. Make the Linux build error name the appindicator development header. Require the dynamically discovered appindicator library to appear in AppDir after the existing `ldd` traversal. Keep the Windows system-WebView2 policy unchanged.
- [ ] **Step 4: Install the supplied public README sources**
Replace this repository's `README.md` with the supplied English source at the workspace root and add the supplied Russian source as `README.ru.md`. Add an image strip after “What is Verstak?” / “Что такое Верстак?” that links to three tracked PNG screenshots and uses matching localized alt text.
- [ ] **Step 5: Produce real screenshots**
Build and run the desktop app against `/home/mirivlad/Nextcloud/Verstak/VerstakVault` on the active X display. Capture:
1. Overview showing recent work and entry points;
2. a populated workspace Files/Notes view;
3. Activity with a suggested session and its Journal review.
Use `scrot` or ImageMagick `import`, crop only surrounding desktop chrome, inspect every PNG visually, and do not add generated mockups. Remove all non-user-safe text from the test vault before capture.
- [ ] **Step 6: Verify documentation and package contracts, commit, push**
```bash
./scripts/test-package-formats.sh
git diff --check
test -s docs/screenshots/overview.png
test -s docs/screenshots/workspace-files-notes.png
test -s docs/screenshots/activity-journal.png
```
```bash
git add packaging/deb/control scripts/build.sh scripts/package-appimage.sh scripts/test-package-formats.sh README.md README.ru.md docs/screenshots
git commit -m "docs: add public README and product screenshots"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 6: Complete verification and GitHub alpha releases
**Files:**
- No source changes expected unless verification exposes a defect.
- [ ] **Step 1: Run all checks**
```bash
GOCACHE=/tmp/verstak-go-cache ./scripts/test.sh
./scripts/check.sh
./scripts/test-package-formats.sh
./scripts/test-build-windows.sh
cd ../verstak-official-plugins && ./scripts/check.sh && ./scripts/test-package-portable.sh && ./scripts/test-publish-github-release.sh
```
- [ ] **Step 2: Build and checksum both alpha releases**
```bash
cd ../verstak-official-plugins && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
cd ../verstak-desktop && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
```
- [ ] **Step 3: Publish and inspect releases**
```bash
cd ../verstak-official-plugins && ./scripts/publish-github-release.sh v0.1.0-alpha.2
cd ../verstak-desktop && ./scripts/publish-github-release.sh v0.1.0-alpha.2
gh release view v0.1.0-alpha.2 -R mirivlad/verstak-official-plugins
gh release view v0.1.0-alpha.2 -R mirivlad/verstak
```
The final report distinguishes automated verification from the two required real desktop manual checks: tray close/show/quit and a near-future Todo notification while the window is hidden.
@@ -0,0 +1,174 @@
# Native Notifications and System Tray Design
**Status:** approved for implementation on 2026-07-14
## Goal
Deliver Todo reminders as native notifications on Windows and Linux, and keep
Verstak running in the system tray when its main window is closed. The feature
must work in the portable Windows archive, Debian package, and AppImage.
## Scope
- The desktop core owns notification delivery, scheduling, and tray lifetime.
- `verstak.todo` owns the Todo-specific reminder policy and text.
- No new official notifications plugin is introduced. A dynamic plugin cannot
call Wails directly, so it cannot be the native-notification transport.
- macOS is out of scope for this alpha. The interfaces remain platform-neutral
where that costs nothing.
## Tray behavior
On Windows and Linux, a tray icon is registered before the Wails event loop
starts. Its menu contains exactly two actions:
1. **Show Verstak** — shows and focuses the existing main window.
2. **Quit** — exits the process deliberately.
Closing the main window with its window-manager close control hides the window
and keeps the process, plugins, local browser receiver, and reminder scheduler
alive. It does not terminate the application. The quit action temporarily
allows the close lifecycle to finish and then exits normally.
The app has a single-instance lock. If a user launches the executable while an
instance is hidden in the tray, the existing instance shows its window instead
of creating a second process.
The implementation uses `github.com/getlantern/systray` through a small
`internal/shell/tray` adapter. It uses `Register`, rather than its blocking
`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived
from the tracked project logo is encoded in the tray package, so a clean build
does not depend on ignored Wails-generated icon files. The library accepts PNG
icon bytes on both target platforms.
## Notification capability and permission
The core registers the capability:
```
verstak/core/notifications/v1
```
Plugins that use it must both require that capability and declare the
`notifications.schedule` permission. The plugin-host API exposes only two
operations within the calling plugin namespace:
```
api.notifications.replace(items)
api.notifications.clear()
```
`replace` is an atomic desired-state replacement, not an append operation. An
item contains a plugin-local stable `id`, an ISO-8601 UTC `dueAt`, a title, and
a body. The core supplies the plugin ID and rejects calls from disabled,
missing-permission, or undeclared-capability plugins. It also validates empty
IDs, duplicate IDs, invalid timestamps, and unsafe oversized text.
No plugin can send arbitrary immediate native notifications or address another
plugin's schedules in this alpha.
## Scheduler and persistence
`internal/core/notifications` persists one canonical schedule file at:
```
<vault>/.verstak/notifications/schedules.json
```
Each record contains `{pluginId, id, dueAt, title, body, sentForDueAt}`.
The composite `(pluginId, id)` is unique. Replacing an item with the same due
time preserves `sentForDueAt`; changing `dueAt` clears it. Replacing a plugin's
list removes its stale records. This provides deterministic cancellation for
completed, deleted, and rescheduled Todos.
The manager starts after Wails reaches `OnDomReady`, initializes Wails native
notifications, and evaluates the persisted schedule immediately and then at
least every 30 seconds. A sender is injected behind an interface for unit
tests. After a successful delivery, the manager atomically records
`sentForDueAt`. A delivery error leaves the schedule pending and is logged for
a later retry.
An expired record that has not been sent is delivered once after the next app
start. A record already sent for its current due time is never sent again.
Completely quitting Verstak stops the scheduler: no separate daemon or OS
background service is added. Hiding the window in the tray does **not** stop it.
The core calls `CleanupNotifications` during shutdown, including on Linux where
it releases the D-Bus connection.
## Todo behavior
`verstak.todo` adds the core notifications capability to `requires` and adds
the `notifications.schedule` permission. After every successful Todo storage
write, it derives the complete desired reminder list:
- include only open Todos with a valid `reminderAt`;
- use the Todo ID as the stable notification ID;
- convert local `datetime-local` input to an ISO-8601 UTC instant;
- use the Todo title in the notification body and locale-aware reminder text;
- call `api.notifications.replace` with the full list.
The same replacement runs after loading persisted Todos, so a transient
schedule-write failure repairs itself next time the Todo view is opened. A
schedule API failure does not roll back Todo data; the UI reports the failure
instead. The existing in-view overdue/reminder badge remains useful context and
is not removed.
## Packaging
`getlantern/systray` requires CGO. The Windows release build already uses
`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the
tray dependency included. Linux build instructions add
`libayatana-appindicator3-dev`. The Debian package declares the corresponding
runtime dependency `libayatana-appindicator3-1`.
The existing AppImage packager traverses `ldd` for the desktop executable and
copies non-glibc runtime libraries. Its verification is extended to prove that
the appindicator library is present in the AppDir when the tray implementation
is compiled in.
## Public README and product screenshots
The workspace-root `README.md` and `README.ru.md` supplied by the maintainer
become the public repository documents: the English source replaces this
repository's `README.md`, and the Russian source is committed as
`README.ru.md`. Their existing language links and public-release instructions
are retained.
Three screenshots are captured from the real desktop application using the
test vault, then committed under `docs/screenshots/`:
1. `overview.png` — returning to recent work and useful next actions;
2. `workspace-files-notes.png` — ordinary vault files and Markdown notes in a
workspace;
3. `activity-journal.png` — review of a factual activity session as a Journal
entry.
Both README variants include the same three images with localized alt text.
They are factual UI captures, not generated illustrative mockups. Capture data
must be limited to the disposable test vault and inspected before commit so no
credentials or personal content are published.
## Test and manual verification
Automated tests cover:
- schedule replacement, cancellation, rescheduling, persistence, one-time
overdue delivery, failed-send retry, and permission/capability rejection;
- Todo desired-list derivation and calls after create/edit/status/delete;
- close policy: ordinary close hides, explicit quit permits shutdown;
- tray controller action wiring and second-instance window reveal;
- Linux/Windows build scripts and package dependency expectations.
Manual smoke tests are required because neither unit tests nor Playwright can
assert a real desktop notification area or OS toast:
1. On Linux and Windows, start Verstak, close its window, use the tray menu to
reveal it, and use **Quit** to terminate it.
2. Set a Todo reminder for a near future time, hide the window in the tray, and
observe one native notification.
3. Quit before a future reminder, relaunch after it expires, and observe one
overdue notification with no duplicate on the next scheduler scan.
4. Inspect all three README screenshots at their committed size and confirm
that they show only intended test-vault data and explain the feature named
in their localized captions.
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Desktop localization', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('switches shell language live and persists the choice', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-language="system"]')).toHaveAttribute('aria-checked', 'true');
await page.locator('[data-settings-language="ru"]').click();
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
await expect(page.locator('.vault-status')).toContainText('Хранилище: открыто');
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
await page.reload();
await waitForAppReady(page);
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
});
test('switches back to English without reloading', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="en"]').click();
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Settings');
await expect(page.locator('.vault-status')).toContainText('Vault: open');
});
});
+13 -3
View File
@@ -9,13 +9,19 @@
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
import * as App from '../wailsjs/go/api/App';
import { debug } from './lib/log/debug.js';
import { onMount } from 'svelte';
import { tick } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { i18n } from './lib/i18n/index.js';
let currentView = 'workspace';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let needsVaultSelection = false;
let loading = true;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
let activeView = null;
let activeViewPluginId = '';
@@ -48,6 +54,7 @@
const name = workspaceName(workspace);
return {
id: name,
workspaceId: workspace?.id || workspace?.workspaceId || '',
type: workspace?.type || 'space',
title: workspace?.title || name,
name,
@@ -212,6 +219,7 @@
const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned');
if (settings?.debug) debug.enable({ persist: false });
debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
@@ -375,11 +383,13 @@
await checkVault();
pushNavigation();
});
onDestroy(unsubscribeLocale);
</script>
{#if loading}
<div class="app-loading">
<p>Loading Verstak...</p>
<p>{tr('app.loading')}</p>
</div>
{:else if needsVaultSelection}
<VaultSelection />
+225
View File
@@ -0,0 +1,225 @@
export default {
'app.loading': 'Loading Verstak...',
'settings.title': 'Settings',
'settings.language': 'Language',
'settings.language.system': 'System / Системный',
'settings.language.en': 'English',
'settings.language.ru': 'Русский',
'settings.pluginManager': 'Plugin Manager',
'statusBar.label': 'Status bar',
'vault.label': 'Vault: {status}',
'vault.status.open': 'open',
'vault.status.closed': 'closed',
'vault.status.unknown': 'unknown',
'sidebar.tools': 'Tools',
'sidebar.error.contributions': 'Failed to load plugin contributions',
'sidebar.error.load': 'Failed to load sidebar',
'sidebar.error.ui': 'Plugin UI error',
'workspace.overview': 'Overview',
'workspace.search': 'Workspace search',
'workspace.tools': 'Workspace tools',
'workspace.tool': 'Workspace tool',
'workspace.emptyTools': 'No workspace tools available',
'workspace.emptyToolsHint': 'Enable plugins with workspace tools or open Plugin Manager from settings.',
'workspace.select': 'Select a workspace',
'workspace.selectHint': 'Use the + button in Workspaces to add your first project.',
'common.details': 'Details',
'common.plugin': 'Plugin',
'common.component': 'Component',
'pluginView.error': 'Plugin view error',
'pluginView.failed': 'Plugin UI failed',
'pluginView.noVisual': 'This plugin does not provide a visual view yet.',
'pluginView.viewId': 'View ID',
'pluginView.bundleUnavailable': 'Frontend bundle unavailable',
'pluginView.unavailable': 'View is unavailable.',
'pluginView.select': 'Select a plugin view from the sidebar',
'pluginView.selectHint': 'Plugin views will appear here',
'common.retry': '⟳ Retry',
'common.source': 'Source',
'common.status': 'Status',
'common.capability': 'Capability',
'common.provider': 'Provider',
'status.loaded': 'loaded',
'status.degraded': 'degraded',
'status.failed': 'failed',
'status.disabled': 'disabled',
'status.missing': 'missing',
'pluginManager.reloading': '⟳ Reloading...',
'pluginManager.reload': '⟳ Reload',
'pluginManager.scanning': 'Scanning plugin directories...',
'pluginManager.summary.plugins': '{count} plugin(s) discovered',
'pluginManager.summary.capabilities': '{count} capabilities registered',
'pluginManager.summary.permissions': '{count} permissions known',
'pluginManager.scanSummary': 'Plugin scan summary',
'pluginManager.health': 'Plugin Health',
'pluginManager.permissionRisk': 'Permission Risk',
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
'pluginManager.none': 'No plugins found',
'pluginManager.scannedDirs': 'Plugin directories scanned:',
'pluginManager.userPlugins': 'user plugins',
'pluginManager.bundledPlugins': 'bundled plugins (app directory)',
'pluginManager.installHint': 'Place a plugin folder with plugin.json in one of these directories and click Reload.',
'pluginManager.missingTitle': 'Missing Installed Plugins',
'pluginManager.missingHint': 'These plugins are required by this vault but their packages are not installed locally.',
'pluginManager.missingPackage': "This plugin is listed in the vault's desired plugins but the package is not installed.",
'pluginManager.capabilityRegistry': 'Capability Registry ({count})',
'pluginManager.settingsError': 'Settings Error',
'pluginManager.pluginSettings': 'Plugin Settings',
'pluginManager.settingsBundleUnavailable': 'Settings panel frontend bundle not available',
'common.none': 'none',
'common.unknown': 'unknown',
'pluginCard.count.views': '{count} view(s)',
'pluginCard.count.commands': '{count} command(s)',
'pluginCard.count.searchProviders': '{count} search provider(s)',
'pluginCard.count.sidebar': '{count} sidebar item(s)',
'pluginCard.count.statusbar': '{count} status bar item(s)',
'pluginCard.count.openProviders': '{count} openProviders',
'pluginCard.count.workspace': '{count} workspace item(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions',
'pluginCard.technicalDetails': 'Technical details',
'pluginCard.apiVersion': 'API Version',
'pluginCard.root': 'Root',
'pluginCard.provides': 'Provides',
'pluginCard.requires': 'Requires',
'pluginCard.missingRequired': 'Missing required capabilities',
'pluginCard.optionalRequires': 'Optional Requires',
'pluginCard.optionalUnavailable': 'Optional capabilities not available — plugin running in degraded mode',
'pluginCard.permissions': 'Permissions',
'pluginCard.enabling': '⟳ Enabling...',
'pluginCard.enable': '▶ Enable',
'pluginCard.disabling': '⟳ Disabling...',
'pluginCard.disable': '⏸ Disable',
'pluginCard.openVault': 'Open a vault to manage plugin state',
'pluginCard.missingUiPermission': 'Plugin has UI contributions but lacks ui.register permission',
'common.loading': 'Loading...',
'common.browse': 'Browse...',
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
'vaultSelection.createError': 'Could not create vault: {error}',
'vaultSelection.openError': 'Could not open vault: {error}',
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
'vaultSelection.subtitle': 'Choose a vault to start working',
'vaultSelection.createTitle': 'Create a new vault',
'vaultSelection.createHint': 'Create a local vault folder for workspaces and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault',
'vaultSelection.openTitle': 'Open an existing vault',
'vaultSelection.openHint': 'Use a vault that is already on this computer.',
'vaultSelection.opening': 'Opening...',
'vaultSelection.open': 'Open existing',
'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel',
'common.close': 'Close',
'workspaceTree.title': 'Workspaces',
'workspaceTree.new': 'New workspace',
'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a workspace template',
'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename workspace',
'workspaceTree.trash': 'Trash workspace',
'workspaceTree.create': 'Create workspace',
'workspaceTree.namePlaceholder': 'Workspace name',
'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...',
'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser Inbox',
'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now',
'command.openSyncSettings': 'Open Sync Settings',
'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled',
'command.result': '{title} {status}',
'command.statusHandled': 'handled',
'command.palette': 'Command Palette',
'command.run': 'Run command',
'command.none': 'No commands',
'search.indexing': 'Indexing...',
'search.placeholder': 'Search',
'search.global': 'Global search',
'search.noResults': 'No results',
'search.type.workspace': 'Workspace',
'search.type.tool': 'Tool',
'search.type.folder': 'Folder',
'search.type.file': 'File',
'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser Inbox',
'search.type.activity': 'Activity',
'workbench.noProvider': 'No provider',
'workbench.noViewer': 'No viewer/editor available',
'workbench.noResource': 'No resource opened',
'bundle.noFrontend': 'Plugin has no frontend bundle',
'bundle.notFound': 'Plugin not found',
'bundle.loadFailed': 'Failed to load bundle: {error}',
'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Bundle execution error: {error}',
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
'bundle.componentMissing': 'Component "{component}" not found in bundle. Available: {available}',
'bundle.mountError': 'Component mount error: {error}',
'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Unexpected error: {error}',
'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components',
'bundle.loading': 'Loading plugin bundle...',
'overview.filter.all': 'All',
'overview.filter.notes': 'Notes',
'overview.filter.files': 'Files',
'overview.filter.captures': 'Captures',
'overview.filter.journal': 'Journal',
'overview.notes': 'Notes',
'overview.files': 'Files',
'overview.captures': 'Captures',
'overview.activity': 'Activity',
'overview.journal': 'Journal',
'overview.attention': 'Needs attention',
'overview.openNotes': 'Open Notes',
'overview.openFiles': 'Open Files',
'overview.reviewInbox': 'Review Inbox',
'overview.viewActivity': 'View Activity',
'overview.openJournal': 'Open Journal',
'overview.openTodos': 'Open Todos',
'overview.reviewCandidate': 'Review candidate',
'overview.reviewPending': 'Review pending items',
'overview.count.totalRecent.one': '{total} total · {recent} recent change',
'overview.count.totalRecent.many': '{total} total · {recent} recent changes',
'overview.count.recentChanges.one': '{count} recent change',
'overview.count.recentChanges.many': '{count} recent changes',
'overview.count.captures.one': '{count} capture to review',
'overview.count.captures.many': '{count} captures to review',
'overview.count.events.one': '{count} recorded event',
'overview.count.events.many': '{count} recorded events',
'overview.count.journal.one': '{count} journal entry',
'overview.count.journal.many': '{count} journal entries',
'overview.count.pending.one': '{count} pending item',
'overview.count.pending.many': '{count} pending items',
'overview.loadingContext': 'Loading workspace context...',
'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent workspace activity',
'overview.refresh': 'Refresh',
'overview.summary': 'Workspace overview summary',
'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this workspace.',
'overview.loadingSignals': 'Loading workspace signals...',
'overview.noResume': 'No clear resume point yet',
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
'overview.recentChanges': 'Recent changes',
'overview.recentChangesHint': 'Latest meaningful activity in this workspace.',
'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.',
'overview.attentionHint': 'Pending captures, urgent todos, and possible journal entries.',
'overview.loadingPending': 'Loading pending items...',
'overview.noPending': 'No pending captures, urgent todos, or possible journal entries.',
'overview.keyResources': 'Key resources',
'overview.time.none': 'No timestamp',
'overview.time.now': 'Just now',
'overview.time.minutes': '{count} min ago',
'overview.time.hours': '{count}h ago',
'overview.time.yesterday': 'Yesterday',
'overview.time.days': '{count} days ago',
};
+225
View File
@@ -0,0 +1,225 @@
export default {
'app.loading': 'Загрузка Верстака...',
'settings.title': 'Настройки',
'settings.language': 'Язык',
'settings.language.system': 'System / Системный',
'settings.language.en': 'English',
'settings.language.ru': 'Русский',
'settings.pluginManager': 'Менеджер плагинов',
'statusBar.label': 'Строка состояния',
'vault.label': 'Хранилище: {status}',
'vault.status.open': 'открыто',
'vault.status.closed': 'закрыто',
'vault.status.unknown': 'неизвестно',
'sidebar.tools': 'Инструменты',
'sidebar.error.contributions': 'Не удалось загрузить элементы плагинов',
'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в рабочем пространстве',
'workspace.tools': 'Инструменты рабочего пространства',
'workspace.tool': 'Инструмент рабочего пространства',
'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите рабочее пространство',
'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.',
'common.details': 'Подробнее',
'common.plugin': 'Плагин',
'common.component': 'Компонент',
'pluginView.error': 'Ошибка представления плагина',
'pluginView.failed': 'Ошибка интерфейса плагина',
'pluginView.noVisual': 'Этот плагин пока не предоставляет визуальное представление.',
'pluginView.viewId': 'ID представления',
'pluginView.bundleUnavailable': 'Frontend bundle недоступен',
'pluginView.unavailable': 'Представление недоступно.',
'pluginView.select': 'Выберите представление плагина на боковой панели',
'pluginView.selectHint': 'Представления плагинов появятся здесь',
'common.retry': '⟳ Повторить',
'common.source': 'Источник',
'common.status': 'Состояние',
'common.capability': 'Capability',
'common.provider': 'Поставщик',
'status.loaded': 'загружено',
'status.degraded': 'ограничено',
'status.failed': 'ошибка',
'status.disabled': 'выключено',
'status.missing': 'отсутствует',
'pluginManager.reloading': '⟳ Обновление...',
'pluginManager.reload': '⟳ Обновить',
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}',
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
'pluginManager.scanSummary': 'Сводка проверки плагинов',
'pluginManager.health': 'Состояние плагинов',
'pluginManager.permissionRisk': 'Риск разрешений',
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
'pluginManager.none': 'Плагины не найдены',
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
'pluginManager.userPlugins': 'пользовательские плагины',
'pluginManager.bundledPlugins': 'встроенные плагины (каталог приложения)',
'pluginManager.installHint': 'Поместите папку плагина с plugin.json в один из этих каталогов и нажмите «Обновить».',
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})',
'pluginManager.settingsError': 'Ошибка настроек',
'pluginManager.pluginSettings': 'Настройки плагина',
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
'common.none': 'нет',
'common.unknown': 'неизвестно',
'pluginCard.count.views': 'представлений: {count}',
'pluginCard.count.commands': 'команд: {count}',
'pluginCard.count.searchProviders': 'поставщиков поиска: {count}',
'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов рабочего пространства: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.',
'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса',
'pluginCard.technicalDetails': 'Технические сведения',
'pluginCard.apiVersion': 'Версия API',
'pluginCard.root': 'Корневой каталог',
'pluginCard.provides': 'Предоставляет',
'pluginCard.requires': 'Требует',
'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities',
'pluginCard.optionalRequires': 'Необязательные зависимости',
'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме',
'pluginCard.permissions': 'Разрешения',
'pluginCard.enabling': '⟳ Включение...',
'pluginCard.enable': '▶ Включить',
'pluginCard.disabling': '⟳ Выключение...',
'pluginCard.disable': '⏸ Выключить',
'pluginCard.openVault': 'Откройте хранилище для управления состоянием плагина',
'pluginCard.missingUiPermission': 'Плагин добавляет элементы интерфейса, но не имеет разрешения ui.register',
'common.loading': 'Загрузка...',
'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище: {error}',
'vaultSelection.openError': 'Не удалось открыть хранилище: {error}',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище',
'vaultSelection.openTitle': 'Открыть существующее хранилище',
'vaultSelection.openHint': 'Используйте хранилище, которое уже находится на этом компьютере.',
'vaultSelection.opening': 'Открытие...',
'vaultSelection.open': 'Открыть',
'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'workspaceTree.title': 'Рабочие пространства',
'workspaceTree.new': 'Новое рабочее пространство',
'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства',
'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать рабочее пространство',
'workspaceTree.trash': 'Переместить рабочее пространство в корзину',
'workspaceTree.create': 'Создать рабочее пространство',
'workspaceTree.namePlaceholder': 'Название рабочего пространства',
'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...',
'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть входящие из браузера',
'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас',
'command.openSyncSettings': 'Открыть настройки синхронизации',
'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}',
'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду',
'command.none': 'Команды не найдены',
'search.indexing': 'Индексирование...',
'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Рабочее пространство',
'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка',
'search.type.file': 'Файл',
'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Входящие из браузера',
'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить bundle: {error}',
'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Ошибка выполнения bundle: {error}',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}',
'bundle.mountError': 'Ошибка подключения компонента: {error}',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Непредвиденная ошибка: {error}',
'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты',
'bundle.loading': 'Загрузка bundle плагина...',
'overview.filter.all': 'Все',
'overview.filter.notes': 'Заметки',
'overview.filter.files': 'Файлы',
'overview.filter.captures': 'Сохранённое',
'overview.filter.journal': 'Журнал',
'overview.notes': 'Заметки',
'overview.files': 'Файлы',
'overview.captures': 'Сохранённое',
'overview.activity': 'Активность',
'overview.journal': 'Журнал',
'overview.attention': 'Требует внимания',
'overview.openNotes': 'Открыть заметки',
'overview.openFiles': 'Открыть файлы',
'overview.reviewInbox': 'Разобрать входящие',
'overview.viewActivity': 'Открыть активность',
'overview.openJournal': 'Открыть журнал',
'overview.openTodos': 'Открыть задачи',
'overview.reviewCandidate': 'Просмотреть запись',
'overview.reviewPending': 'Разобрать ожидающее',
'overview.count.totalRecent.one': 'Всего: {total} · недавних изменений: {recent}',
'overview.count.totalRecent.many': 'Всего: {total} · недавних изменений: {recent}',
'overview.count.recentChanges.one': 'Недавних изменений: {count}',
'overview.count.recentChanges.many': 'Недавних изменений: {count}',
'overview.count.captures.one': 'На разбор: {count}',
'overview.count.captures.many': 'На разбор: {count}',
'overview.count.events.one': 'Событий: {count}',
'overview.count.events.many': 'Событий: {count}',
'overview.count.journal.one': 'Записей журнала: {count}',
'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных рабочего пространства...',
'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности',
'overview.refresh': 'Обновить',
'overview.summary': 'Сводка рабочего пространства',
'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.',
'overview.loadingSignals': 'Загрузка рабочих данных...',
'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.',
'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',
'overview.attentionHint': 'Неразобранное сохранённое, срочные задачи и возможные записи журнала.',
'overview.loadingPending': 'Загрузка ожидающих элементов...',
'overview.noPending': 'Нет неразобранного сохранённого, срочных задач или возможных записей журнала.',
'overview.keyResources': 'Ключевые ресурсы',
'overview.time.none': 'Время не указано',
'overview.time.now': 'Только что',
'overview.time.minutes': '{count} мин. назад',
'overview.time.hours': '{count} ч. назад',
'overview.time.yesterday': 'Вчера',
'overview.time.days': '{count} дн. назад',
};
+200
View File
@@ -0,0 +1,200 @@
import englishShellCatalog from './catalogs/en.js';
import russianShellCatalog from './catalogs/ru.js';
const LANGUAGE_PREFERENCES = new Set(['system', 'en', 'ru']);
const CONTRIBUTION_TEXT_FIELDS = {
views: 'title',
commands: 'title',
settingsPanels: 'title',
sidebarItems: 'title',
fileActions: 'label',
noteActions: 'label',
contextMenuEntries: 'label',
searchProviders: 'label',
statusBarItems: 'label',
openProviders: 'title',
workspaceItems: 'title',
};
function normalizeSystemLanguages(languages) {
const values = Array.isArray(languages) ? languages : [languages];
return values.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
}
export function resolveLocale(preference, systemLanguages = []) {
if (!LANGUAGE_PREFERENCES.has(preference)) {
throw new Error(`unsupported language preference: ${preference}`);
}
if (preference === 'en' || preference === 'ru') return preference;
return normalizeSystemLanguages(systemLanguages).some((locale) => locale === 'ru' || locale.startsWith('ru-'))
? 'ru'
: 'en';
}
function interpolate(message, params) {
if (!params) return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder
));
}
function browserLanguages() {
if (typeof navigator === 'undefined') return ['en'];
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) return navigator.languages;
return [navigator.language || 'en'];
}
export function createI18n(options = {}) {
const shellCatalogs = options.shellCatalogs || { en: {}, ru: {} };
let systemLanguages = options.systemLanguages || browserLanguages;
let catalogLoader = options.loadPluginCatalog || (async () => ({}));
let preference = 'system';
let locale = 'en';
const listeners = new Set();
const pluginConfigs = new Map();
const pluginCatalogs = new Map();
function configure(next = {}) {
if (next.systemLanguages) systemLanguages = next.systemLanguages;
if (next.loadPluginCatalog) catalogLoader = next.loadPluginCatalog;
}
function notify() {
listeners.forEach((listener) => listener(locale));
}
async function initialize(initialPreference = 'system') {
preference = LANGUAGE_PREFERENCES.has(initialPreference) ? initialPreference : 'system';
locale = resolveLocale(preference, systemLanguages());
}
async function loadCatalog(pluginId, catalogLocale) {
if (!catalogLocale) return;
let catalogs = pluginCatalogs.get(pluginId);
if (!catalogs) {
catalogs = new Map();
pluginCatalogs.set(pluginId, catalogs);
}
if (catalogs.has(catalogLocale)) return;
const messages = await catalogLoader(pluginId, catalogLocale);
catalogs.set(catalogLocale, { ...(messages || {}) });
}
async function loadPlugin(pluginId, localization) {
if (!pluginId || !localization || !localization.defaultLocale || !localization.locales) return;
pluginConfigs.set(pluginId, localization);
const requested = localization.locales[locale] ? locale : '';
const defaultLocale = localization.defaultLocale;
await Promise.all(Array.from(new Set([requested, defaultLocale].filter(Boolean))).map((item) => loadCatalog(pluginId, item)));
}
async function setLanguagePreference(nextPreference) {
const nextLocale = resolveLocale(nextPreference, systemLanguages());
preference = nextPreference;
locale = nextLocale;
await Promise.all(Array.from(pluginConfigs.entries()).map(([pluginId, config]) => (
loadPlugin(pluginId, config).catch((error) => {
console.warn(`[i18n] failed to load ${pluginId} catalog for ${nextLocale}:`, error);
})
)));
notify();
}
function t(key, params, fallback) {
const message = shellCatalogs[locale]?.[key]
?? shellCatalogs.en?.[key]
?? fallback
?? key;
return interpolate(message, params);
}
function translatePlugin(pluginId, key, params, fallback) {
const config = pluginConfigs.get(pluginId);
const catalogs = pluginCatalogs.get(pluginId);
const message = catalogs?.get(locale)?.[key]
?? catalogs?.get(config?.defaultLocale)?.[key]
?? fallback
?? key;
return interpolate(message, params);
}
function localizeContributions(pluginId, contributions) {
if (!contributions) return contributions;
const localized = { ...contributions };
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
if (!Array.isArray(contributions[point])) return;
localized[point] = contributions[point].map((item) => ({
...item,
[field]: translatePlugin(
pluginId,
`contributions.${point}.${item.id}.${field}`,
undefined,
item[field],
),
}));
});
return localized;
}
function localizePlugin(plugin) {
if (!plugin) return plugin;
const hasStateWrapper = !!plugin.manifest;
const manifest = hasStateWrapper ? plugin.manifest : plugin;
const pluginId = manifest.id;
const localizedManifest = {
...manifest,
name: translatePlugin(pluginId, 'manifest.name', undefined, manifest.name),
description: translatePlugin(pluginId, 'manifest.description', undefined, manifest.description),
contributes: localizeContributions(pluginId, manifest.contributes),
};
return hasStateWrapper ? { ...plugin, manifest: localizedManifest } : localizedManifest;
}
function localizeContributionSummary(summary) {
if (!summary) return summary;
const localized = { ...summary };
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
if (!Array.isArray(summary[point])) return;
localized[point] = summary[point].map((item) => ({
...item,
[field]: translatePlugin(
item.pluginId,
`contributions.${point}.${item.id}.${field}`,
undefined,
item[field],
),
}));
});
return localized;
}
function subscribe(listener) {
listeners.add(listener);
listener(locale);
return () => listeners.delete(listener);
}
return {
configure,
initialize,
getLanguagePreference: () => preference,
getLocale: () => locale,
setLanguagePreference,
t,
subscribe,
loadPlugin,
translatePlugin,
localizeContributions,
localizePlugin,
localizeContributionSummary,
};
}
export const i18n = createI18n({
shellCatalogs: {
en: englishShellCatalog,
ru: russianShellCatalog,
},
});
export const t = (...args) => i18n.t(...args);
+4 -2
View File
@@ -127,9 +127,11 @@ export var debug = {
log: log,
logf: logf,
isEnabled: function () { return ENABLED; },
enable: function () {
enable: function (options) {
ENABLED = true;
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
if (!options || options.persist !== false) {
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
}
},
disable: function () {
ENABLED = false;
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
import { createPluginAPI } from './VerstakPluginAPI.js';
@@ -18,6 +19,13 @@
let currentComponent = null;
let currentAPI = null;
let currentPropsKey = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
@@ -32,9 +40,14 @@
}
onDestroy(() => {
unsubscribeLocale?.();
cleanup();
});
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
});
function cleanup() {
if (currentAPI && typeof currentAPI.dispose === 'function') {
try {
@@ -93,11 +106,17 @@
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
loadState = 'error';
errorText = info.status === 'no-frontend'
? 'Plugin has no frontend bundle'
: 'Plugin not found';
? tr('bundle.noFrontend')
: tr('bundle.notFound');
return;
}
try {
await i18n.loadPlugin(pId, info.localization);
} catch (catalogError) {
console.warn(`[PluginBundleHost] localization unavailable for ${pId}:`, catalogError);
}
// Check if bundle already loaded for this plugin
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
if (!reg[pId]) {
@@ -106,7 +125,7 @@
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content');
errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
return;
}
@@ -117,7 +136,7 @@
fn();
} catch (e) {
loadState = 'error';
errorText = 'Bundle execution error: ' + e.message;
errorText = tr('bundle.executionError', { error: e.message });
console.error('[PluginBundleHost] bundle exec error:', e);
return;
}
@@ -125,7 +144,7 @@
// Verify registration happened
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
loadState = 'error';
errorText = 'Bundle loaded but no VerstakPluginRegister call detected';
errorText = tr('bundle.registrationMissing');
return;
}
}
@@ -135,8 +154,10 @@
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = 'Component "' + compId + '" not found in bundle. Available: '
+ (Object.keys(components).join(', ') || 'none');
errorText = tr('bundle.componentMissing', {
component: compId,
available: Object.keys(components).join(', ') || tr('common.none'),
});
return;
}
@@ -156,16 +177,16 @@
errorText = '';
} catch (e) {
loadState = 'error';
errorText = 'Component mount error: ' + e.message;
errorText = tr('bundle.mountError', { error: e.message });
console.error('[PluginBundleHost] mount error:', e);
}
} else {
loadState = 'error';
errorText = 'Mount container not available';
errorText = tr('bundle.mountUnavailable');
}
} catch (e) {
loadState = 'error';
errorText = 'Unexpected error: ' + (e.message || e);
errorText = tr('bundle.unexpectedError', { error: e.message || e });
console.error('[PluginBundleHost] error:', e);
}
}
@@ -180,22 +201,22 @@
<div class="plugin-bundle-host">
{#if loadState === 'idle'}
<div class="host-state idle">
<p>Select a plugin view from the sidebar</p>
<p>{tr('pluginView.select')}</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin View Error</p>
<p class="error-title">{tr('pluginView.error')}</p>
<div class="error-details">
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
<p><strong>Component:</strong> {currentComponent || 'unknown'}</p>
<p class="error-message">{errorText || 'Unknown error'}</p>
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
{#if pluginInfo}
<p class="error-meta">Frontend entry: {pluginInfo.entry || 'none'}</p>
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">Available components: {getComponentList().join(', ')}</p>
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if}
</div>
</div>
@@ -204,7 +225,7 @@
{#if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>Loading plugin bundle...</p>
<p>{tr('bundle.loading')}</p>
</div>
{/if}
<div
@@ -1,4 +1,5 @@
import * as App from '../../../wailsjs/go/api/App';
import { i18n } from '../i18n/index.js';
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
@@ -144,6 +145,24 @@ export function createPluginAPI(pluginId) {
return {
pluginId: pluginId,
i18n: {
getLocale: function() {
assertActive('i18n.getLocale');
return i18n.getLocale();
},
t: function(key, params, fallback) {
assertActive('i18n.t(' + key + ')');
return i18n.translatePlugin(pluginId, key, params, fallback);
},
onDidChangeLocale: function(listener) {
assertActive('i18n.onDidChangeLocale');
if (typeof listener !== 'function') {
throw new Error('i18n.onDidChangeLocale requires a listener function');
}
return trackCleanup(i18n.subscribe(listener));
}
},
ui: {
openSettings: function(panelId) {
assertActive('ui.openSettings');
@@ -201,6 +220,24 @@ export function createPluginAPI(pluginId) {
}
},
notifications: {
replace: function(items) {
assertActive('notifications.replace');
if (!Array.isArray(items)) {
throw new Error('notifications.replace requires an array');
}
return callBackendErrorString(pluginId, 'notifications.replace', function() {
return App.ReplacePluginNotifications(pluginId, items);
});
},
clear: function() {
assertActive('notifications.clear');
return callBackendErrorString(pluginId, 'notifications.clear', function() {
return App.ClearPluginNotifications(pluginId);
});
}
},
settings: {
read: async function(key) {
assertActive('settings.read');
@@ -217,12 +254,10 @@ export function createPluginAPI(pluginId) {
if (!key) {
throw new Error('settings.write requires a key');
}
const settings = await this.read();
settings[key] = value;
await callBackendErrorString(pluginId, 'settings.write(' + key + ')', function() {
return App.WritePluginSettings(pluginId, settings);
return App.WritePluginSetting(pluginId, key, value);
});
return settings;
return this.read();
},
writeAll: function(settings) {
assertActive('settings.writeAll');
@@ -245,6 +280,26 @@ export function createPluginAPI(pluginId) {
return data || {};
});
},
readNDJSON: function(name) {
assertActive('storage.data.readNDJSON(' + name + ')');
if (!name) {
throw new Error('storage.data.readNDJSON requires a name');
}
return callBackend(pluginId, 'storage.data.readNDJSON(' + name + ')', function() {
return App.ReadPluginDataNDJSON(pluginId, name);
}).then(function(records) {
return Array.isArray(records) ? records : [];
});
},
writeNDJSON: function(name, records) {
assertActive('storage.data.writeNDJSON(' + name + ')');
if (!name) {
throw new Error('storage.data.writeNDJSON requires a name');
}
return callBackendErrorString(pluginId, 'storage.data.writeNDJSON(' + name + ')', function() {
return App.WritePluginDataNDJSON(pluginId, name, Array.isArray(records) ? records : []);
});
},
write: function(name, data) {
assertActive('storage.data.write(' + name + ')');
if (!name) {
@@ -392,6 +447,12 @@ export function createPluginAPI(pluginId) {
return App.OpenVaultPathExternal(pluginId, relativePath);
});
},
openURL: function(url) {
assertActive('files.openURL');
return callBackendErrorString(pluginId, 'files.openURL', function() {
return App.OpenExternalURL(pluginId, String(url == null ? '' : url));
});
},
showInFolder: function(relativePath) {
assertActive('files.showInFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
@@ -1,5 +1,7 @@
<script>
import Icon from '../ui/Icon.svelte';
import { onDestroy } from 'svelte';
import { i18n } from '../i18n/index.js';
export let p = {};
export let capabilities = [];
export let permissions = [];
@@ -8,6 +10,13 @@
export let settingsPanels = [];
export let onEnable = () => {};
export let onDisable = () => {};
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onDestroy(unsubscribeLocale);
$: m = p.manifest || {};
$: pluginId = m.id || 'unknown';
@@ -39,14 +48,14 @@
$: contribSummary = (() => {
const parts = [];
if (contribCounts.views > 0) parts.push(contribCounts.views + ' view' + (contribCounts.views !== 1 ? 's' : ''));
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : ''));
if (contribCounts.searchProviders > 0) parts.push(contribCounts.searchProviders + ' searchProvider' + (contribCounts.searchProviders !== 1 ? 's' : ''));
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : ''));
if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : ''));
if (contribCounts.workspaceItems > 0) parts.push(contribCounts.workspaceItems + ' workspace' + (contribCounts.workspaceItems !== 1 ? 's' : ''));
return parts.length > 0 ? parts.join(', ') : 'none';
if (contribCounts.views > 0) parts.push(tr('pluginCard.count.views', { count: contribCounts.views }));
if (contribCounts.commands > 0) parts.push(tr('pluginCard.count.commands', { count: contribCounts.commands }));
if (contribCounts.searchProviders > 0) parts.push(tr('pluginCard.count.searchProviders', { count: contribCounts.searchProviders }));
if (contribCounts.sidebar > 0) parts.push(tr('pluginCard.count.sidebar', { count: contribCounts.sidebar }));
if (contribCounts.statusbar > 0) parts.push(tr('pluginCard.count.statusbar', { count: contribCounts.statusbar }));
if (contribCounts.openProviders > 0) parts.push(tr('pluginCard.count.openProviders', { count: contribCounts.openProviders }));
if (contribCounts.workspaceItems > 0) parts.push(tr('pluginCard.count.workspace', { count: contribCounts.workspaceItems }));
return parts.length > 0 ? parts.join(', ') : tr('common.none');
})();
$: dangerousPermissions = (m.permissions || []).filter(name => {
@@ -81,11 +90,11 @@
<strong>{pluginId}</strong>
<span class="version">v{m.version || '?'}</span>
</div>
<span class="status-badge" style="color: {statusColor}">{p.status}</span>
<span class="status-badge" style="color: {statusColor}">{tr(`status.${p.status}`, undefined, p.status)}</span>
</div>
{#if p.status === 'degraded'}
<p class="degraded-text">Plugin is usable, but some optional capabilities are unavailable.</p>
<p class="degraded-text">{tr('pluginCard.degraded')}</p>
{/if}
{#if m.description}
@@ -94,34 +103,34 @@
<div class="card-meta">
<div class="meta-row">
<span class="label">Name:</span>
<span class="label">{tr('pluginCard.name')}:</span>
<span>{m.name || '-'}</span>
</div>
<div class="meta-row">
<span class="label">Source:</span>
<span>{m.source || 'unknown'}</span>
<span class="label">{tr('common.source')}:</span>
<span>{m.source || tr('common.unknown')}</span>
</div>
<div class="meta-row">
<span class="label">Contributions:</span>
<span class="label">{tr('pluginCard.contributions')}:</span>
<span>{contribSummary}</span>
</div>
</div>
<details class="plugin-details">
<summary>Technical details</summary>
<summary>{tr('pluginCard.technicalDetails')}</summary>
<div class="card-meta technical-meta">
<div class="meta-row">
<span class="label">API Version:</span>
<span class="label">{tr('pluginCard.apiVersion')}:</span>
<span>{m.apiVersion || '-'}</span>
</div>
<div class="meta-row">
<span class="label">Root:</span>
<span class="label">{tr('pluginCard.root')}:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
</div>
<div class="section">
<span class="section-title">Provides</span>
<span class="section-title">{tr('pluginCard.provides')}</span>
<div class="tags">
{#each m.provides || [] as cap}
<span class="tag provides">{cap}</span>
@@ -131,7 +140,7 @@
{#if m.requires && m.requires.length > 0}
<div class="section">
<span class="section-title">Requires</span>
<span class="section-title">{tr('pluginCard.requires')}</span>
<div class="tags">
{#each m.requires as req}
{@const found = capabilities.some(c => c.name === req)}
@@ -142,14 +151,14 @@
{/each}
</div>
{#if missingRequired.length > 0}
<p class="warning"><Icon name="warning" size={12} /> Missing required capabilities: {missingRequired.join(', ')}</p>
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingRequired')}: {missingRequired.join(', ')}</p>
{/if}
</div>
{/if}
{#if m.optionalRequires && m.optionalRequires.length > 0}
<div class="section">
<span class="section-title">Optional Requires</span>
<span class="section-title">{tr('pluginCard.optionalRequires')}</span>
<div class="tags">
{#each m.optionalRequires as opt}
{@const found = capabilities.some(c => c.name === opt)}
@@ -160,14 +169,14 @@
{/each}
</div>
{#if missingOptional.length > 0}
<p class="info"><Icon name="warning" size={12} /> Optional capabilities not available — plugin running in degraded mode</p>
<p class="info"><Icon name="warning" size={12} /> {tr('pluginCard.optionalUnavailable')}</p>
{/if}
</div>
{/if}
{#if m.permissions && m.permissions.length > 0}
<div class="section">
<span class="section-title">Permissions</span>
<span class="section-title">{tr('pluginCard.permissions')}</span>
<div class="tags">
{#each m.permissions as perm}
{@const isDangerous = dangerousPermissions.includes(perm)}
@@ -190,28 +199,28 @@
<div class="card-actions">
{#if hasSettingsPanel}
<button class="btn-settings" on:click={() => window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: { pluginId: m.id, panelId: settingsPanels[0]?.id } }))} type="button" disabled={isDisabled || p.status === 'failed'}>
<Icon name="gear" size={14} /> Settings
<Icon name="gear" size={14} /> {tr('settings.title')}
</button>
{/if}
{#if vaultOpen && canToggle}
{#if isDisabled}
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
{#if busyAction === 'enabling'}⟳ Enabling...{:else}▶ Enable{/if}
{#if busyAction === 'enabling'}{tr('pluginCard.enabling')}{:else}{tr('pluginCard.enable')}{/if}
</button>
{:else}
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
{#if busyAction === 'disabling'}⟳ Disabling...{:else}⏸ Disable{/if}
{#if busyAction === 'disabling'}{tr('pluginCard.disabling')}{:else}{tr('pluginCard.disable')}{/if}
</button>
{/if}
{/if}
{#if !vaultOpen && canToggle}
<span class="vault-hint">Open a vault to manage plugin state</span>
<span class="vault-hint">{tr('pluginCard.openVault')}</span>
{/if}
</div>
<!-- Permission warnings -->
{#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
<p class="warning"><Icon name="warning" size={12} /> Plugin has UI contributions but lacks ui.register permission</p>
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingUiPermission')}</p>
{/if}
</div>
@@ -2,9 +2,10 @@
import Icon from '../ui/Icon.svelte';
import PluginCard from './PluginCard.svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount, tick } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo, WriteFrontendLog } from '../../../wailsjs/go/api/App';
import { debug } from '../log/debug.js';
import { i18n } from '../i18n/index.js';
let plugins = [];
let capabilities = [];
@@ -20,6 +21,12 @@
let settingsError = null;
let settingsPluginInfo = null;
let lastOpenedKey = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
// Per-action loading state — shows feedback on specific buttons without hiding the whole list
let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
@@ -109,7 +116,10 @@
try {
debug.log('[PluginManager] loadAll: calling GetPlugins...');
const p = await GetPlugins();
plugins = p || [];
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
debug.log('[PluginManager] loadAll: GetPlugins returned', plugins.length, 'plugins');
for (var i = 0; i < plugins.length; i++) {
debug.log('[PluginManager] loadAll: plugin[' + i + ']:', plugins[i].manifest?.id, 'status:', plugins[i].status, 'enabled:', plugins[i].enabled);
@@ -133,7 +143,7 @@
vaultStatus = v || { status: 'unknown', path: '', vaultId: '' };
capabilities = caps || [];
permissions = perms || [];
contributions = contribs || {};
contributions = i18n.localizeContributionSummary(contribs || {});
debug.log('[PluginManager] loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
WriteFrontendLog('PluginManager', 'loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
} catch (e) {
@@ -156,7 +166,18 @@
WriteFrontendLog('PluginManager', 'loadAll: END, loading=false');
}
onMount(() => { loadAll(); });
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadAll();
});
loadAll();
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
});
async function reload() {
debug.log('[PluginManager] reload: START');
@@ -272,48 +293,48 @@
<header>
<div class="header-left">
<h2>Plugin Manager</h2>
<h2>{tr('settings.pluginManager')}</h2>
{#if vaultStatus.status !== 'unknown'}
<span class="vault-badge" class:vault-open={vaultStatus.status === 'open'} class:vault-not-created={vaultStatus.status === 'not-created'} class:vault-closed={vaultStatus.status === 'closed'} class:vault-error={vaultStatus.status === 'error'}>
Vault: {vaultStatus.status}{#if vaultStatus.path} ({vaultStatus.path}){/if}
{tr('vault.label', { status: tr(`vault.status.${vaultStatus.status}`, undefined, vaultStatus.status) })}{#if vaultStatus.path} ({vaultStatus.path}){/if}
</span>
{/if}
</div>
<button class="reload-btn" on:click={reload} type="button" disabled={loading || reloading}>
{reloading ? '⟳ Reloading...' : '⟳ Reload'}
{reloading ? tr('pluginManager.reloading') : tr('pluginManager.reload')}
</button>
</header>
{#if loading}
<div class="loading">Scanning plugin directories...</div>
<div class="loading">{tr('pluginManager.scanning')}</div>
{:else if error}
<div class="error">
<Icon name="warning" size={24} class="error-icon" />
<div class="error-message">{error}</div>
<button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button>
<button class="retry-btn" on:click={loadAll} type="button">{tr('common.retry')}</button>
</div>
{:else}
<div class="summary">
<span class="badge">{totalPlugins} plugin(s) discovered</span>
<span class="badge">{totalCaps} capabilities registered</span>
<span class="badge">{totalPerms} permissions known</span>
<span class="badge">{tr('pluginManager.summary.plugins', { count: totalPlugins })}</span>
<span class="badge">{tr('pluginManager.summary.capabilities', { count: totalCaps })}</span>
<span class="badge">{tr('pluginManager.summary.permissions', { count: totalPerms })}</span>
</div>
<div class="scan-summary" aria-label="Plugin scan summary">
<div class="scan-summary" aria-label={tr('pluginManager.scanSummary')}>
<section class="scan-card" data-plugin-manager-summary="health">
<div class="scan-card-title">Plugin Health</div>
<div class="scan-card-title">{tr('pluginManager.health')}</div>
<div class="scan-metrics">
<span data-plugin-status-summary="loaded"><strong>{statusSummary.loaded}</strong> loaded</span>
<span data-plugin-status-summary="degraded"><strong>{statusSummary.degraded}</strong> degraded</span>
<span data-plugin-status-summary="failed"><strong>{statusSummary.failed}</strong> failed</span>
<span data-plugin-status-summary="disabled"><strong>{statusSummary.disabled}</strong> disabled</span>
<span data-plugin-status-summary="loaded"><strong>{statusSummary.loaded}</strong> {tr('status.loaded')}</span>
<span data-plugin-status-summary="degraded"><strong>{statusSummary.degraded}</strong> {tr('status.degraded')}</span>
<span data-plugin-status-summary="failed"><strong>{statusSummary.failed}</strong> {tr('status.failed')}</span>
<span data-plugin-status-summary="disabled"><strong>{statusSummary.disabled}</strong> {tr('status.disabled')}</span>
</div>
</section>
<section class="scan-card" data-plugin-manager-summary="risk">
<div class="scan-card-title">Permission Risk</div>
<div class="scan-card-title">{tr('pluginManager.permissionRisk')}</div>
<div class="scan-metrics">
<span data-plugin-risk-summary="elevated-permissions"><strong>{elevatedPermissionPluginCount}</strong> plugins request elevated permissions</span>
<span data-plugin-risk-summary="elevated-permissions">{tr('pluginManager.elevatedPermissions', { count: elevatedPermissionPluginCount })}</span>
</div>
</section>
</div>
@@ -325,13 +346,13 @@
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<p>No plugins found</p>
<p class="hint">Plugin directories scanned:</p>
<p>{tr('pluginManager.none')}</p>
<p class="hint">{tr('pluginManager.scannedDirs')}</p>
<ul class="hint-list">
<li><code>~/.config/verstak/plugins/</code> — user plugins</li>
<li><code>./plugins/</code> — bundled plugins (app directory)</li>
<li><code>~/.config/verstak/plugins/</code> — {tr('pluginManager.userPlugins')}</li>
<li><code>./plugins/</code> — {tr('pluginManager.bundledPlugins')}</li>
</ul>
<p class="hint">Place a plugin folder with <code>plugin.json</code> in one of these directories and click Reload.</p>
<p class="hint">{tr('pluginManager.installHint')}</p>
</div>
{:else}
<div class="plugin-list">
@@ -343,8 +364,8 @@
{#if missingInstalled.length > 0}
<div class="missing-section">
<h3>Missing Installed Plugins</h3>
<p class="missing-hint">These plugins are required by this vault but their packages are not installed locally.</p>
<h3>{tr('pluginManager.missingTitle')}</h3>
<p class="missing-hint">{tr('pluginManager.missingHint')}</p>
<div class="plugin-list">
{#each missingInstalled as mp}
<div class="plugin-card missing-card">
@@ -354,12 +375,12 @@
<strong>{mp.id}</strong>
{#if mp.version}<span class="version">v{mp.version}</span>{/if}
</div>
<span class="status-badge" style="color: #e94560">missing</span>
<span class="status-badge" style="color: #e94560">{tr('status.missing')}</span>
</div>
<p class="missing-text">
This plugin is listed in the vault's desired plugins but the package is not installed.
{tr('pluginManager.missingPackage')}
{#if mp.source && mp.source !== 'unknown'}
<span class="source-hint">Source: {mp.source}</span>
<span class="source-hint">{tr('common.source')}: {mp.source}</span>
{/if}
</p>
</div>
@@ -370,10 +391,10 @@
{#if capabilities.length > 0}
<details class="registry-section">
<summary>Capability Registry ({totalCaps})</summary>
<summary>{tr('pluginManager.capabilityRegistry', { count: totalCaps })}</summary>
<table>
<thead>
<tr><th>Capability</th><th>Provider</th><th>Source</th><th>Status</th></tr>
<tr><th>{tr('common.capability')}</th><th>{tr('common.provider')}</th><th>{tr('common.source')}</th><th>{tr('common.status')}</th></tr>
</thead>
<tbody>
{#each capabilities as cap}
@@ -394,9 +415,9 @@
{#key `settings-${settingsPluginId}`}
{#if settingsError}
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label="Settings Error">
<div class="modal" role="dialog" aria-modal="true" aria-label={tr('pluginManager.settingsError')}>
<div class="modal-header">
<h3>Settings Error</h3>
<h3>{tr('pluginManager.settingsError')}</h3>
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
</div>
<div class="modal-body">
@@ -406,21 +427,21 @@
</div>
{:else if settingsPanel}
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
<div class="modal" role="dialog" aria-modal="true" aria-label={tr('pluginManager.pluginSettings')}>
<div class="modal-header">
<h3>{settingsPanel.title}</h3>
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
</div>
<div class="modal-body">
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
<p class="settings-hint">{tr('common.plugin')}: <code>{settingsPluginId}</code></p>
{#if settingsPluginInfo && settingsPluginInfo.entry}
<PluginBundleHost
pluginId={settingsPluginId}
componentId={settingsPanel.component || settingsPanel.id}
/>
{:else}
<p class="settings-hint">Component: <code>{settingsPanel.component}</code></p>
<p class="placeholder">Settings panel frontend bundle not available</p>
<p class="settings-hint">{tr('common.component')}: <code>{settingsPanel.component}</code></p>
<p class="placeholder">{tr('pluginManager.settingsBundleUnavailable')}</p>
{/if}
</div>
</div>
+43 -19
View File
@@ -1,7 +1,8 @@
<script>
import { onDestroy, tick } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js';
let open = false;
let query = '';
@@ -10,18 +11,25 @@
let inputEl = null;
let statusMessage = '';
let statusType = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
const shellCommands = [
{ id: 'verstak.shell.open-overview', title: 'Open Overview', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
{ id: 'verstak.shell.open-files', title: 'Open Files', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
{ id: 'verstak.shell.open-activity', title: 'Open Activity', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
{ id: 'verstak.shell.open-browser-inbox', title: 'Open Browser Inbox', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
{ id: 'verstak.shell.create-markdown', title: 'Create Markdown File', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
{ id: 'verstak.shell.create-text', title: 'Create Text File', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
{ id: 'verstak.shell.sync-now', title: 'Sync Now', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
{ id: 'verstak.shell.open-sync-settings', title: 'Open Sync Settings', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
{ id: 'verstak.shell.open-plugin-manager', title: 'Open Plugin Manager', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
$: shellCommands = [
{ id: 'verstak.shell.open-overview', title: tr('command.openOverview'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
{ id: 'verstak.shell.open-files', title: tr('command.openFiles'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
{ id: 'verstak.shell.open-activity', title: tr('command.openActivity'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
{ id: 'verstak.shell.open-browser-inbox', title: tr('command.openBrowserInbox'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
{ id: 'verstak.shell.create-markdown', title: tr('command.createMarkdown'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
{ id: 'verstak.shell.create-text', title: tr('command.createText'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
{ id: 'verstak.shell.sync-now', title: tr('command.syncNow'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
{ id: 'verstak.shell.open-sync-settings', title: tr('command.openSyncSettings'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
{ id: 'verstak.shell.open-plugin-manager', title: tr('command.openPluginManager'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
];
$: normalizedQuery = query.trim().toLowerCase();
@@ -44,14 +52,21 @@
App.GetContributions().catch(() => ({})),
]);
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
const pluginCommands = (contributions.commands || [])
await Promise.all((plugins || []).map((plugin) => i18n.loadPlugin(
plugin.manifest?.id,
plugin.manifest?.localization,
).catch(() => {})));
const localizedPlugins = (plugins || []).map((plugin) => i18n.localizePlugin(plugin));
const localizedPluginById = new Map(localizedPlugins.map((plugin) => [plugin.manifest?.id, plugin]));
const localizedContributions = i18n.localizeContributionSummary(contributions || {});
const pluginCommands = (localizedContributions.commands || [])
.filter((command) => {
const plugin = pluginById.get(command.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.map((command) => {
const plugin = pluginById.get(command.pluginId);
const plugin = localizedPluginById.get(command.pluginId);
return {
...command,
pluginName: plugin?.manifest?.name || command.pluginId,
@@ -170,14 +185,14 @@
if (command.shellAction) {
await runShellCommand(command);
closePalette();
setStatus('success', `${command.title || command.id} handled`);
setStatus('success', tr('command.handled', { title: command.title || command.id }));
return;
}
const result = await executePluginCommand(command.pluginId, command.id, {
source: 'command-palette',
});
closePalette();
setStatus('success', `${command.title || command.id} ${result.status || 'handled'}`);
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
} catch (err) {
setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
}
@@ -221,7 +236,16 @@
window.addEventListener('keydown', onWindowKeydown);
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed && open) loadCommands();
});
});
onDestroy(() => {
unsubscribeLocale?.();
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onWindowKeydown);
window.clearTimeout(setStatus.timer);
@@ -240,19 +264,19 @@
{#if open}
<div class="command-palette-overlay" role="presentation" on:mousedown={onOverlayMouseDown}>
<section class="command-palette" role="dialog" aria-modal="true" aria-label="Command Palette">
<section class="command-palette" role="dialog" aria-modal="true" aria-label={tr('command.palette')}>
<input
bind:this={inputEl}
bind:value={query}
class="command-palette-input"
data-command-palette-input
placeholder="Run command"
aria-label="Run command"
placeholder={tr('command.run')}
aria-label={tr('command.run')}
/>
<div class="command-palette-list" role="listbox">
{#if filteredCommands.length === 0}
<div class="command-palette-empty">No commands</div>
<div class="command-palette-empty">{tr('command.none')}</div>
{:else}
{#each filteredCommands as command, index}
<button
+36 -10
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'markdown', 'log', 'json', 'csv', 'yaml', 'yml', 'toml']);
const FILE_INDEX_LIMIT = 220;
@@ -16,12 +17,26 @@
let loading = true;
let searchTimer = null;
let buildSeq = 0;
let locale = i18n.getLocale();
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: scheduleSearch(query);
onMount(() => {
const unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) buildIndex();
});
buildIndex();
return () => clearTimeout(searchTimer);
return () => {
unsubscribeLocale();
clearTimeout(searchTimer);
};
});
function normalize(value) {
@@ -160,8 +175,9 @@
nodes.forEach(node => {
next.push({
type: 'Workspace',
typeLabel: tr('search.type.workspace'),
title: workspaceTitle(node),
subtitle: 'Workspace',
subtitle: tr('search.type.workspace'),
keywords: `${node.id || ''} ${node.rootPath || ''}`,
rank: 10,
action: 'workspace',
@@ -170,7 +186,15 @@
});
});
const contributions = await resultOrEmpty(App.GetContributions(), {});
const [rawPlugins, rawContributions] = await Promise.all([
resultOrEmpty(App.GetPlugins(), []),
resultOrEmpty(App.GetContributions(), {}),
]);
await Promise.all((rawPlugins || []).map((plugin) => i18n.loadPlugin(
plugin.manifest?.id,
plugin.manifest?.localization,
).catch(() => {})));
const contributions = i18n.localizeContributionSummary(rawContributions || {});
const viewByPluginId = new Map();
(contributions.views || []).forEach(view => {
if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
@@ -178,6 +202,7 @@
(contributions.sidebarItems || []).forEach(item => {
next.push({
type: 'Tool',
typeLabel: tr('search.type.tool'),
title: item.title || item.id,
subtitle: item.pluginId || '',
keywords: `${item.id || ''} ${item.view || ''}`,
@@ -194,6 +219,7 @@
const snippet = await readFileSnippet(path);
next.push({
type: entry.type === 'folder' ? 'Folder' : 'File',
typeLabel: tr(entry.type === 'folder' ? 'search.type.folder' : 'search.type.file'),
title: path.split('/').pop() || path,
subtitle: path,
keywords: snippet,
@@ -205,9 +231,9 @@
}
const pluginItems = await Promise.all([
indexPluginSettings('verstak.journal', 'Journal', 50, viewByPluginId.get('verstak.journal'), nodes),
indexPluginSettings('verstak.browser-inbox', 'Browser Inbox', 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
indexPluginSettings('verstak.activity', 'Activity', 60, viewByPluginId.get('verstak.activity'), nodes),
indexPluginSettings('verstak.journal', tr('search.type.journal'), 50, viewByPluginId.get('verstak.journal'), nodes),
indexPluginSettings('verstak.browser-inbox', tr('search.type.browserInbox'), 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
indexPluginSettings('verstak.activity', tr('search.type.activity'), 60, viewByPluginId.get('verstak.activity'), nodes),
]);
if (seq !== buildSeq) return;
@@ -293,8 +319,8 @@
on:focus={handleFocus}
on:blur={() => setTimeout(() => focused = false, 120)}
type="search"
placeholder={loading ? 'Indexing...' : 'Search'}
aria-label="Global search"
placeholder={loading ? tr('search.indexing') : tr('search.placeholder')}
aria-label={tr('search.global')}
data-global-search-input
/>
</div>
@@ -310,11 +336,11 @@
on:mousedown|preventDefault={() => openResult(item)}
>
<span class="global-search-result-title">{item.title}</span>
<span class="global-search-result-meta">{item.type} · {item.subtitle}</span>
<span class="global-search-result-meta">{item.typeLabel || item.type} · {item.subtitle}</span>
</button>
{/each}
{:else}
<div class="global-search-empty vt-empty-title">No results</div>
<div class="global-search-empty vt-empty-title">{tr('search.noResults')}</div>
{/if}
</div>
{/if}
+24 -6
View File
@@ -5,6 +5,7 @@
import GlobalSearch from './GlobalSearch.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true;
@@ -16,6 +17,13 @@
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let sidebarItems = [];
let errorMessage = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: vaultOpen = vaultStatus.status === 'open';
@@ -31,14 +39,18 @@
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
App.GetContributions().catch(() => { contribErr = true; return {}; }),
]);
plugins = p || [];
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
const localizedContributions = i18n.localizeContributionSummary(contribs || {});
vaultStatus = v;
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
if (contribErr) {
errorMessage = 'Failed to load plugin contributions';
errorMessage = tr('sidebar.error.contributions');
}
sidebarItems = (contribs.sidebarItems || []).filter(item => {
sidebarItems = (localizedContributions.sidebarItems || []).filter(item => {
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
if (!plugin) return false;
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
@@ -50,18 +62,24 @@
debug.log('[Sidebar] onMount: ERROR:', String(e));
flog('onMount: ERROR: ' + String(e));
console.error('[Sidebar] load error:', e);
errorMessage = 'Failed to load sidebar';
errorMessage = tr('sidebar.error.load');
}
debug.log('[Sidebar] onMount: END');
flog('onMount: END');
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadSidebar();
});
loadSidebar();
window.addEventListener('verstak:plugins-changed', loadSidebar);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:plugins-changed', loadSidebar);
});
@@ -85,7 +103,7 @@
{#if sidebarItems.length > 0}
<div class="sidebar-section">
<span class="section-label">Tools</span>
<span class="section-label">{tr('sidebar.tools')}</span>
{#each sidebarItems as item}
<button
class="nav-item plugin-item vt-list-row"
@@ -107,7 +125,7 @@
{#if errorMessage}
<span class="sidebar-error">
<Icon name="warning" size={10} class="sidebar-error-icon" />
Plugin UI error
{tr('sidebar.error.ui')}
</span>
{/if}
</div>
+72 -5
View File
@@ -2,26 +2,41 @@
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let items = [];
let settingsPanels = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let settingsOpen = false;
let locale = i18n.getLocale();
let selectedLanguage = i18n.getLanguagePreference();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: leftItems = items.filter((item) => item.position === 'left');
$: centerItems = items.filter((item) => item.position === 'center');
$: rightItems = items.filter((item) => item.position === 'right');
$: vaultOpen = vaultStatus.status === 'open';
$: vaultLabel = vaultStatus.status && vaultStatus.status !== 'unknown' ? 'Vault: ' + vaultStatus.status : 'Vault: unknown';
$: vaultStatusLabel = tr(`vault.status.${vaultStatus.status || 'unknown'}`, undefined, vaultStatus.status || 'unknown');
$: vaultLabel = tr('vault.label', { status: vaultStatusLabel });
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
async function loadStatusBar() {
const [plugins, contributions, vault] = await Promise.all([
const [rawPlugins, rawContributions, vault] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetContributions().catch(() => ({})),
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
]);
await Promise.all((rawPlugins || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
const plugins = (rawPlugins || []).map((plugin) => i18n.localizePlugin(plugin));
const contributions = i18n.localizeContributionSummary(rawContributions || {});
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
vaultStatus = vault || { status: 'unknown', path: '', vaultId: '' };
items = (contributions.statusBarItems || [])
@@ -56,6 +71,16 @@
}));
}
async function selectLanguage(language) {
const err = await App.UpdateAppSettings({ language });
if (err) {
throw new Error(err);
}
await i18n.setLanguagePreference(language);
selectedLanguage = language;
settingsOpen = false;
}
function toggleSettings(event) {
event.stopPropagation();
settingsOpen = !settingsOpen;
@@ -66,6 +91,12 @@
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
selectedLanguage = i18n.getLanguagePreference();
if (changed) loadStatusBar();
});
loadStatusBar();
window.addEventListener('verstak:plugins-changed', loadStatusBar);
window.addEventListener('verstak:vault-opened', loadStatusBar);
@@ -73,13 +104,14 @@
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
window.removeEventListener('verstak:vault-opened', loadStatusBar);
window.removeEventListener('click', closeSettings);
});
</script>
<footer class="status-bar" aria-label="Status bar">
<footer class="status-bar" aria-label={tr('statusBar.label')}>
<div class="status-bar-group status-left">
<span
class="vault-status"
@@ -132,7 +164,7 @@
class="settings-button"
class:active={settingsOpen}
type="button"
title="Settings"
title={tr('settings.title')}
aria-haspopup="menu"
aria-expanded={settingsOpen}
data-settings-menu-button
@@ -143,6 +175,22 @@
</button>
{#if settingsOpen}
<div class="settings-menu" role="menu">
<div class="settings-menu-heading">{tr('settings.language')}</div>
{#each ['system', 'en', 'ru'] as language}
<button
class="settings-menu-item language-item"
class:active-language={selectedLanguage === language}
type="button"
role="menuitemradio"
aria-checked={selectedLanguage === language}
data-settings-language={language}
on:click={() => selectLanguage(language)}
>
<span class="language-check" aria-hidden="true">{selectedLanguage === language ? '✓' : ''}</span>
<span>{tr(`settings.language.${language}`)}</span>
</button>
{/each}
<div class="settings-menu-separator"></div>
<button
class="settings-menu-item"
type="button"
@@ -151,7 +199,7 @@
on:click={openPluginManager}
>
<Icon name="puzzle" size={14} class="settings-menu-icon" />
<span>Plugin Manager</span>
<span>{tr('settings.pluginManager')}</span>
</button>
{#if settingsPanels.length > 0}
<div class="settings-menu-separator"></div>
@@ -320,6 +368,25 @@
color: #ffffff;
}
.settings-menu-heading {
padding: 0.25rem 0.45rem 0.2rem;
color: #7f8aa3;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.language-check {
width: 0.9rem;
color: #4ecca3;
text-align: center;
}
.active-language {
background: rgba(78, 204, 163, 0.1);
}
.settings-menu-separator {
height: 1px;
margin: 0.25rem 0.2rem;
+79 -63
View File
@@ -1,18 +1,12 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import { i18n } from '../i18n/index.js';
export let workspaceRootPath = '';
export let availableTools = [];
const dispatch = createEventDispatcher();
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'notes', label: 'Notes' },
{ key: 'files', label: 'Files' },
{ key: 'captures', label: 'Captures' },
{ key: 'journal', label: 'Journal' },
];
const LOW_VALUE_RECENT_TYPES = new Set([
'workspace.selected',
'case.selected',
@@ -37,6 +31,20 @@
let totalNotes = 0;
let loadedWorkspaceRoot = '';
let toolProbe = 0;
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: FILTERS = ['all', 'notes', 'files', 'captures', 'journal'].map((key) => ({
key,
label: tr(`overview.filter.${key}`),
}));
function countText(key, count, params = {}) {
return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params });
}
$: hasNotes = hasTool('notes', availableTools);
$: hasTodos = hasTool('todo', availableTools);
@@ -55,18 +63,21 @@
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
$: summaryItems = [
{ key: 'notes', label: 'Notes', count: totalNotes, detail: totalAndRecentLabel(totalNotes, noteRecentChanges), actionKind: 'notes', actionLabel: 'Open Notes' },
{ key: 'files', label: 'Files', count: fileRecentChanges, detail: countLabel(fileRecentChanges, 'recent change'), actionKind: 'files', actionLabel: 'Open Files' },
{ key: 'captures', label: 'Captures', count: unprocessedCaptures.length, detail: captureReviewLabel(unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: 'Review Inbox' },
{ key: 'activity', label: 'Activity', count: activityEvents.length, detail: countLabel(activityEvents.length, 'recorded event'), actionKind: 'activity', actionLabel: 'View Activity' },
{ key: 'journal', label: 'Journal', count: journalEntries.length, detail: journalEntryLabel(journalEntries.length), actionKind: 'journal', actionLabel: 'Open Journal' },
{ key: 'attention', label: 'Needs attention', count: needsAttention.length, detail: countLabel(needsAttention.length, 'pending item'), actionKind: attentionActionKind, actionLabel: 'Review pending items' },
{ key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') },
{ key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') },
{ key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') },
{ key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') },
{ key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') },
{ key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') },
];
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
toolProbe += 1;
});
onDestroy(() => unsubscribeLocale?.());
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
loadOverview();
}
@@ -125,6 +136,7 @@
].flatMap(key => normalizeRows(settings?.[key])).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
if (tagged !== workspace) return false;
if (String(item.globalState || 'inbox') === 'archived') return false;
const captureId = String(item.captureId || '');
if (!captureId || seen.has(captureId)) return !captureId;
seen.add(captureId);
@@ -245,17 +257,17 @@
function relativeTime(value) {
const ms = timeMs({ occurredAt: value });
if (!ms) return 'No timestamp';
if (!ms) return tr('overview.time.none');
const diff = Date.now() - ms;
if (diff < 0) return absoluteTime(value);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return 'Just now';
if (diff < hour) return `${Math.floor(diff / minute)} min ago`;
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
if (diff < 2 * day) return 'Yesterday';
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`;
if (diff < minute) return tr('overview.time.now');
if (diff < hour) return tr('overview.time.minutes', { count: Math.floor(diff / minute) });
if (diff < day) return tr('overview.time.hours', { count: Math.floor(diff / hour) });
if (diff < 2 * day) return tr('overview.time.yesterday');
if (diff < 7 * day) return tr('overview.time.days', { count: Math.floor(diff / day) });
const date = new Date(ms);
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
}
@@ -291,11 +303,11 @@
}
function actionForCategory(category) {
if (category === 'notes') return { kind: 'notes', label: 'Open Notes' };
if (category === 'files') return { kind: 'files', label: 'Open Files' };
if (category === 'captures') return { kind: 'browser-inbox', label: 'Review Inbox' };
if (category === 'journal') return { kind: 'journal', label: 'Open Journal' };
return { kind: 'activity', label: 'View Activity' };
if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') };
if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') };
if (category === 'captures') return { kind: 'browser-inbox', label: tr('overview.reviewInbox') };
if (category === 'journal') return { kind: 'journal', label: tr('overview.openJournal') };
return { kind: 'activity', label: tr('overview.viewActivity') };
}
function buildRecentChanges(events, captureRows, journalRows) {
@@ -323,7 +335,7 @@
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox',
actionLabel: 'Review Inbox',
actionLabel: tr('overview.reviewInbox'),
}));
const journalItems = journalRows.map(item => ({
id: item.entryId || `journal:${timeValue(item)}`,
@@ -333,7 +345,7 @@
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'journal',
actionLabel: 'Open Journal',
actionLabel: tr('overview.openJournal'),
}));
return sortByTime([...activityItems, ...captureItems, ...journalItems]).slice(0, 12);
}
@@ -374,7 +386,7 @@
time: todoTimeValue(item),
absolute: absoluteTime(todoTimeValue(item)),
actionKind: 'todo',
actionLabel: 'Open Todos',
actionLabel: tr('overview.openTodos'),
}));
const captureCandidates = sortByTime(captureRows).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
@@ -384,7 +396,7 @@
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox',
actionLabel: 'Review Inbox',
actionLabel: tr('overview.reviewInbox'),
}));
const noteCandidates = sortByTime(events)
.filter(item => isResumeEvent(item) && activityCategory(item) === 'notes')
@@ -400,7 +412,7 @@
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'journal',
actionLabel: 'Open Journal',
actionLabel: tr('overview.openJournal'),
}));
return [...todoCandidates, ...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4);
}
@@ -411,14 +423,14 @@
title: captureTitle(item),
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
actionKind: 'browser-inbox',
actionLabel: 'Review Inbox',
actionLabel: tr('overview.reviewInbox'),
}));
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
id: item.candidateId || `work-session:${timeValue(item)}`,
title: 'Possible journal entry',
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
actionKind: 'journal',
actionLabel: 'Review candidate',
actionLabel: tr('overview.reviewCandidate'),
toolRequest: { type: 'work-session-candidate', candidate: item },
}));
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
@@ -426,7 +438,7 @@
title: todoTitle(item),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
actionKind: 'todo',
actionLabel: 'Open Todos',
actionLabel: tr('overview.openTodos'),
}));
return [...todoItems, ...captureItems, ...candidateItems].slice(0, 6);
}
@@ -481,7 +493,7 @@
title: overview.name || fileName(overview.relativePath) || 'Overview.md',
meta: overview.relativePath || 'Workspace overview note',
actionKind: hasNotes ? 'notes' : 'files',
actionLabel: hasNotes ? 'Open Notes' : 'Open Files',
actionLabel: hasNotes ? tr('overview.openNotes') : tr('overview.openFiles'),
}] : [],
};
}
@@ -490,9 +502,14 @@
const workspaceAtStart = String(workspaceRootPath || '').trim();
loadedWorkspaceRoot = workspaceAtStart;
loading = true;
const [browserSettings, activitySettings, journalSettings, todoSettings, resources] = await Promise.all([
const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([
readPluginSettings('verstak.browser-inbox'),
readPluginSettings('verstak.activity'),
App.ReadPluginDataNDJSON
? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
.then(value => decodeTuple(value, []))
.catch(() => [])
: Promise.resolve([]),
readPluginSettings('verstak.journal'),
readPluginSettings('verstak.todo'),
loadWorkspaceResources(),
@@ -500,11 +517,10 @@
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
captures = browserCaptureRowsForWorkspace(browserSettings);
activityEvents = rowsFor(activitySettings, [
workspaceKey('events:workspace:'),
'events:global',
'events',
]);
activityEvents = normalizeRows(activityRecords).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
return !tagged || tagged === workspaceAtStart;
});
journalEntries = rowsFor(journalSettings, [
workspaceKey('worklog:workspace:'),
'worklog',
@@ -523,24 +539,24 @@
}
</script>
<div class="today-root overview-root" aria-label="Overview" data-overview-root>
<div class="today-root overview-root" aria-label={tr('workspace.overview')} data-overview-root>
<div class="today-header overview-header">
<div>
<h2>Overview</h2>
<h2>{tr('workspace.overview')}</h2>
<p title={lastActive ? absoluteTime(lastActive) : ''}>
{#if loading}
Loading workspace context...
{tr('overview.loadingContext')}
{:else if lastActive}
Last active {relativeTime(lastActive)}
{tr('overview.lastActive', { time: relativeTime(lastActive) })}
{:else}
No recent workspace activity
{tr('overview.noRecentActivity')}
{/if}
</p>
</div>
<button type="button" data-overview-action="refresh" on:click={loadOverview}>Refresh</button>
<button type="button" data-overview-action="refresh" on:click={loadOverview}>{tr('overview.refresh')}</button>
</div>
<div class="today-summary overview-summary" aria-label="Workspace overview summary">
<div class="today-summary overview-summary" aria-label={tr('overview.summary')}>
{#each summaryItems as item}
<button
type="button"
@@ -553,7 +569,7 @@
>
<strong>{loading ? '...' : item.count}</strong>
<span>{item.label}</span>
<small>{loading ? 'Loading...' : item.detail}</small>
<small>{loading ? tr('common.loading') : item.detail}</small>
<em>{item.actionLabel}</em>
</button>
{/each}
@@ -563,11 +579,11 @@
<main class="overview-main">
<section class="today-resume overview-continue" data-overview-section="continue">
<div class="today-resume-copy overview-continue-copy">
<span>Continue working</span>
<h3>Pick up the next useful item in this workspace.</h3>
<span>{tr('overview.continue')}</span>
<h3>{tr('overview.continueHint')}</h3>
</div>
{#if loading}
<p class="today-empty compact">Loading workspace signals...</p>
<p class="today-empty compact">{tr('overview.loadingSignals')}</p>
{:else if continueItems.length}
<div class="overview-continue-list">
{#each continueItems as item}
@@ -588,8 +604,8 @@
</div>
{:else}
<div class="overview-continue-empty">
<strong>No clear resume point yet</strong>
<p>Recent notes, files, captures, and journal entries will appear here.</p>
<strong>{tr('overview.noResume')}</strong>
<p>{tr('overview.noResumeHint')}</p>
</div>
{/if}
</section>
@@ -597,10 +613,10 @@
<section class="today-panel overview-panel overview-recent" data-overview-section="recent">
<div class="today-panel-head overview-panel-head">
<div>
<h3>Recent changes</h3>
<p>Latest meaningful activity in this workspace.</p>
<h3>{tr('overview.recentChanges')}</h3>
<p>{tr('overview.recentChangesHint')}</p>
</div>
<div class="overview-filters" aria-label="Recent changes filter">
<div class="overview-filters" aria-label={tr('overview.recentFilter')}>
{#each FILTERS as filter}
<button
type="button"
@@ -615,7 +631,7 @@
</div>
</div>
{#if loading}
<p class="today-empty">Loading recent changes...</p>
<p class="today-empty">{tr('overview.loadingRecent')}</p>
{:else if filteredRecentChanges.length}
<div class="today-list overview-list">
{#each filteredRecentChanges as item}
@@ -635,7 +651,7 @@
{/each}
</div>
{:else}
<p class="today-empty">No meaningful changes for this filter yet.</p>
<p class="today-empty">{tr('overview.noChanges')}</p>
{/if}
</section>
</main>
@@ -645,12 +661,12 @@
<section class="today-panel overview-panel" data-overview-section="attention">
<div class="today-panel-head overview-panel-head">
<div>
<h3>Needs attention</h3>
<p>Pending captures, urgent todos, and possible journal entries.</p>
<h3>{tr('overview.attention')}</h3>
<p>{tr('overview.attentionHint')}</p>
</div>
</div>
{#if loading}
<p class="today-empty">Loading pending items...</p>
<p class="today-empty">{tr('overview.loadingPending')}</p>
{:else if needsAttention.length}
<div class="today-list overview-list compact">
{#each needsAttention as item}
@@ -662,7 +678,7 @@
{/each}
</div>
{:else}
<p class="today-empty compact">No pending captures, urgent todos, or possible journal entries.</p>
<p class="today-empty compact">{tr('overview.noPending')}</p>
{/if}
</section>
{/if}
@@ -670,7 +686,7 @@
{#if keyResources.length}
<section class="today-panel overview-panel secondary" data-overview-section="key-resources">
<div class="today-panel-head overview-panel-head">
<h3>Key resources</h3>
<h3>{tr('overview.keyResources')}</h3>
</div>
<div class="today-list overview-list compact">
{#each keyResources as item}
+28 -20
View File
@@ -1,7 +1,8 @@
<script>
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let appSettings = {};
let recentVaults = [];
@@ -11,6 +12,13 @@
let opening = false;
let newVaultPath = '';
let openVaultPath = '';
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onDestroy(unsubscribeLocale);
onMount(async () => {
try {
@@ -39,20 +47,20 @@
async function createVault() {
error = '';
if (!newVaultPath.trim()) {
error = 'Choose or enter a folder for the new vault.';
error = tr('vaultSelection.chooseNew');
return;
}
creating = true;
try {
const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) {
error = 'Could not create vault: ' + createErr;
error = tr('vaultSelection.createError', { error: createErr });
creating = false;
return;
}
const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) {
error = 'Could not open vault: ' + openErr;
error = tr('vaultSelection.openError', { error: openErr });
creating = false;
return;
}
@@ -70,14 +78,14 @@
async function openExistingVault() {
error = '';
if (!openVaultPath.trim()) {
error = 'Choose or enter an existing vault.';
error = tr('vaultSelection.chooseExisting');
return;
}
opening = true;
try {
const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) {
error = 'Could not open vault: ' + openErr;
error = tr('vaultSelection.openError', { error: openErr });
opening = false;
return;
}
@@ -98,7 +106,7 @@
try {
const openErr = await App.OpenVault(path);
if (openErr) {
error = 'Could not open vault: ' + openErr;
error = tr('vaultSelection.openError', { error: openErr });
opening = false;
return;
}
@@ -117,7 +125,7 @@
{#if loading}
<div class="vault-selection">
<div class="vault-selection-inner">
<p class="loading-text">Loading...</p>
<p class="loading-text">{tr('common.loading')}</p>
</div>
</div>
{:else}
@@ -130,7 +138,7 @@
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
<h1>Verstak</h1>
<p class="subtitle">Choose a vault to start working</p>
<p class="subtitle">{tr('vaultSelection.subtitle')}</p>
</div>
{#if error}
@@ -142,43 +150,43 @@
<div class="actions">
<div class="action-card">
<h3>Create a new vault</h3>
<p class="hint">Create a local vault folder for workspaces and projects.</p>
<h3>{tr('vaultSelection.createTitle')}</h3>
<p class="hint">{tr('vaultSelection.createHint')}</p>
<div class="input-row">
<input
type="text"
bind:value={newVaultPath}
placeholder="Choose or enter a path..."
placeholder={tr('vaultSelection.pathPlaceholder')}
disabled={creating}
/>
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
Browse...
{tr('common.browse')}
</button>
</div>
<div class="button-row">
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
{creating ? 'Creating...' : 'Create vault'}
{creating ? tr('vaultSelection.creating') : tr('vaultSelection.create')}
</button>
</div>
</div>
<div class="action-card">
<h3>Open an existing vault</h3>
<p class="hint">Use a vault that is already on this computer.</p>
<h3>{tr('vaultSelection.openTitle')}</h3>
<p class="hint">{tr('vaultSelection.openHint')}</p>
<div class="input-row">
<input
type="text"
bind:value={openVaultPath}
placeholder="Choose or enter a path..."
placeholder={tr('vaultSelection.pathPlaceholder')}
disabled={opening}
/>
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
Browse...
{tr('common.browse')}
</button>
</div>
<div class="button-row">
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? 'Opening...' : 'Open existing'}
{opening ? tr('vaultSelection.opening') : tr('vaultSelection.open')}
</button>
</div>
</div>
@@ -186,7 +194,7 @@
{#if recentVaults.length > 0}
<div class="recent-section">
<h3>Recent vaults</h3>
<h3>{tr('vaultSelection.recent')}</h3>
<ul class="recent-list">
{#each recentVaults as path}
<li>
+39 -16
View File
@@ -1,8 +1,9 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
export let activeView = null;
export let activeViewPluginId = null;
@@ -10,18 +11,40 @@
let views = [];
let plugins = [];
let renderError = null;
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onMount(async () => {
async function loadViews() {
try {
const [contribs, pluginList] = await Promise.all([
App.GetContributions().catch(() => ({ views: [] })),
App.GetPlugins().catch(() => []),
]);
views = contribs.views || [];
plugins = pluginList;
await Promise.all((pluginList || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
views = i18n.localizeContributionSummary(contribs || {}).views || [];
plugins = (pluginList || []).map((plugin) => i18n.localizePlugin(plugin));
} catch (e) {
console.error('[ViewContainer] load error:', e);
}
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadViews();
});
loadViews();
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
});
$: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
@@ -39,7 +62,7 @@
}
function onHostError(e) {
renderError = e.detail?.message || 'Plugin view error';
renderError = e.detail?.message || tr('pluginView.error');
}
</script>
@@ -49,9 +72,9 @@
<div class="error-boundary">
<div class="error-fallback vt-inline-alert error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin UI failed</p>
<p class="error-title">{tr('pluginView.failed')}</p>
<details class="error-details">
<summary>Details</summary>
<summary>{tr('common.details')}</summary>
<p class="error-text">{renderError}</p>
</details>
</div>
@@ -72,14 +95,14 @@
/>
{:else}
<div class="placeholder">
<p class="placeholder-label">This plugin does not provide a visual view yet.</p>
<p class="placeholder-label">{tr('pluginView.noVisual')}</p>
<details class="placeholder-details">
<summary>Details</summary>
<p class="placeholder-info"><span class="placeholder-key">Plugin:</span> <strong>{currentView.pluginId}</strong></p>
<p class="placeholder-info"><span class="placeholder-key">View ID:</span> <code>{currentView.id}</code></p>
<p class="placeholder-info"><span class="placeholder-key">Component:</span> <code>{currentView.component}</code></p>
<summary>{tr('common.details')}</summary>
<p class="placeholder-info"><span class="placeholder-key">{tr('common.plugin')}:</span> <strong>{currentView.pluginId}</strong></p>
<p class="placeholder-info"><span class="placeholder-key">{tr('pluginView.viewId')}:</span> <code>{currentView.id}</code></p>
<p class="placeholder-info"><span class="placeholder-key">{tr('common.component')}:</span> <code>{currentView.component}</code></p>
</details>
<p class="placeholder-badge vt-badge">Frontend bundle unavailable</p>
<p class="placeholder-badge vt-badge">{tr('pluginView.bundleUnavailable')}</p>
</div>
{/if}
</div>
@@ -87,12 +110,12 @@
</div>
{:else if activeView}
<div class="view-container empty">
<p>View is unavailable.</p>
<p>{tr('pluginView.unavailable')}</p>
</div>
{:else}
<div class="view-container empty">
<p>Select a plugin view from the sidebar</p>
<p class="sub">Plugin views will appear here</p>
<p>{tr('pluginView.select')}</p>
<p class="sub">{tr('pluginView.selectHint')}</p>
</div>
{/if}
{/key}
+20 -6
View File
@@ -1,8 +1,18 @@
<script>
import { onDestroy } from 'svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
export let openedResource = null;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: providerPluginId = openedResource?.providerPluginId || '';
$: providerComponent = openedResource?.providerComponent || '';
@@ -24,26 +34,30 @@
function closeWorkbench() {
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
}
onDestroy(unsubscribeLocale);
</script>
<div class="workbench-host vt-page">
{#if openedResource?.status === 'no-provider'}
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourcePath}</span>
<span class="workbench-provider vt-badge">No provider</span>
<button class="close-btn btn-ghost btn-icon" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
<p class="vt-empty-title">No viewer/editor available</p>
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
<p class="workbench-meta">{requestMode} · {requestContext}</p>
</div>
{:else if openedResource}
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourcePath}</span>
<span class="workbench-provider vt-badge accent">{providerId}</span>
<button class="close-btn btn-ghost btn-icon" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
{#if debug.isEnabled() && providerId}
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
{/if}
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
@@ -58,7 +72,7 @@
</div>
{:else}
<div class="workbench-empty vt-empty-state">
<p class="vt-empty-title">No resource opened</p>
<p class="vt-empty-title">{tr('workbench.noResource')}</p>
</div>
{/if}
</div>
+28 -11
View File
@@ -4,6 +4,7 @@
import TodaySurface from './TodaySurface.svelte';
import * as App from '../../../wailsjs/go/api/App';
import { onDestroy, onMount } from 'svelte';
import { i18n } from '../i18n/index.js';
export let selectedWorkspaceName = '';
export let nodes = [];
@@ -20,8 +21,14 @@
let requestedToolRequest = null;
let activeToolRequest = null;
let requestedWorkspaceRoot = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up.
const overviewTool = { id: '__overview', title: 'Overview', pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
$: overviewTool = { id: '__overview', title: tr('workspace.overview'), pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
const toolOrder = new Map([
['notes', 10],
@@ -36,6 +43,7 @@
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
@@ -66,10 +74,16 @@
$: if (selectedWorkspaceName) loadTools();
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed && selectedWorkspaceName) loadTools();
});
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
});
@@ -163,8 +177,11 @@
App.GetContributions().catch(() => ({})),
App.GetPlugins().catch(() => []),
]);
contributions = c || {};
plugins = p || [];
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
contributions = i18n.localizeContributionSummary(c || {});
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
const enabledIds = new Set(
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
@@ -186,13 +203,13 @@
<span class="workspace-title vt-page-title">{workspaceTitle}</span>
<span class="workspace-type vt-badge accent">{workspaceType}</span>
</div>
<div class="workspace-search" aria-label="Workspace search">
<div class="workspace-search" aria-label={tr('workspace.search')}>
<GlobalSearch />
</div>
</div>
{#if displayedTools.length > 0}
<div class="workspace-tabs vt-tabbar" role="tablist" aria-label="Workspace tools">
<div class="workspace-tabs vt-tabbar" role="tablist" aria-label={tr('workspace.tools')}>
{#each displayedTools as tool (tool.id + tool.pluginId)}
<button
class="vt-tab"
@@ -207,7 +224,7 @@
</button>
{/each}
</div>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || tr('workspace.tool')}>
{#if activeTool}
{#if activeTool.shell}
<TodaySurface
@@ -219,21 +236,21 @@
<PluginBundleHost
pluginId={activeTool.pluginId}
componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, toolRequest: activeToolRequest }}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, workspaceId, toolRequest: activeToolRequest }}
/>
{/if}
{/if}
</div>
{:else}
<div class="workspace-empty vt-empty-state">
<p class="vt-empty-title">No workspace tools available</p>
<p class="workspace-hint">Enable plugins with workspace tools or open Plugin Manager from settings.</p>
<p class="vt-empty-title">{tr('workspace.emptyTools')}</p>
<p class="workspace-hint">{tr('workspace.emptyToolsHint')}</p>
</div>
{/if}
{:else}
<div class="workspace-empty vt-empty-state">
<p class="vt-empty-title">Select a workspace</p>
<p class="workspace-hint">Use the + button in Workspaces to add your first project.</p>
<p class="vt-empty-title">{tr('workspace.select')}</p>
<p class="workspace-hint">{tr('workspace.selectHint')}</p>
</div>
{/if}
</div>
+34 -18
View File
@@ -8,6 +8,7 @@
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let loading = true;
let localError = '';
@@ -24,14 +25,26 @@
let renamingId = '';
let renameValue = '';
let busyId = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadWorkspaceTemplates();
});
loadWorkspaces();
loadWorkspaceTemplates();
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
@@ -67,7 +80,10 @@
return;
}
workspaceTemplates = Array.isArray(list) ? list : [];
templatePluginNames = (Array.isArray(plugins) ? plugins : []).reduce((names, plugin) => {
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
templatePluginNames = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)).reduce((names, plugin) => {
const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name;
if (id && name) names[id] = name;
@@ -154,11 +170,11 @@
async function doCreate() {
const name = newWorkspaceName.trim();
if (!name) {
createError = 'Name is required';
createError = tr('workspaceTree.nameRequired');
return;
}
if (!selectedTemplate) {
createError = 'Choose a workspace template';
createError = tr('workspaceTree.chooseTemplate');
return;
}
creating = true;
@@ -246,12 +262,12 @@
<div class="wt">
<div class="wt-header">
<span class="wt-title">Workspaces</span>
<button class="wt-btn" on:click={openCreateDialog} title="New workspace" type="button">+</button>
<span class="wt-title">{tr('workspaceTree.title')}</span>
<button class="wt-btn" on:click={openCreateDialog} title={tr('workspaceTree.new')} type="button">+</button>
</div>
{#if loading}
<div class="wt-loading">Loading...</div>
<div class="wt-loading">{tr('common.loading')}</div>
{:else if localError}
<div class="wt-error">{localError}</div>
{/if}
@@ -272,14 +288,14 @@
if (e.key === 'Escape') cancelRename();
}}
/>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title="Save rename" type="button" disabled={busyId === id}>OK</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title="Cancel rename" type="button" disabled={busyId === id}>Cancel</button>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</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="Rename workspace" type="button" disabled={busyId === id}>
<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="Trash workspace" type="button" disabled={busyId === id}>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
<Icon name="trash" size={12} />
</button>
{/if}
@@ -289,20 +305,20 @@
</div>
{#if showCreate}
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label="Create workspace">
<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>New workspace</h2>
<h2>{tr('workspaceTree.new')}</h2>
</div>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>Close</button>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
</div>
<label class="workspace-create-field">
<span>Name</span>
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder="Workspace name" disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
<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>Template</span>
<span>{tr('workspaceTree.template')}</span>
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
{#each workspaceTemplates as template (template.id)}
<option value={template.id}>{template.name}</option>
@@ -323,8 +339,8 @@
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
{/if}
<div class="workspace-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? 'Creating...' : 'Create workspace'}</button>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>Cancel</button>
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? tr('workspaceTree.creating') : tr('workspaceTree.create')}</button>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.cancel')}</button>
</div>
</div>
</div>
+97 -3
View File
@@ -283,6 +283,64 @@
}
};
var russianPluginNames = {
'verstak.platform-test': 'Тест платформы',
'verstak.default-editor': 'Стандартный редактор',
'verstak.files': 'Файлы',
'verstak.notes': 'Заметки',
'verstak.sync': 'Синхронизация',
'verstak.activity': 'Активность',
'verstak.journal': 'Журнал',
'verstak.browser-inbox': 'Входящие из браузера',
'verstak.search': 'Поиск',
'verstak.trash': 'Корзина',
'verstak.todo': 'Задачи',
'verstak.secrets': 'Секреты'
};
var russianContributionLabels = {
'verstak.platform-test.diagnostics': 'Диагностика платформы',
'verstak.platform-test.run-tests': 'Запустить тесты платформы',
'verstak.platform-test.show-version': 'Показать сведения о версии',
'verstak.platform-test.sidebar': 'Тест платформы',
'verstak.platform-test.status': '[OK] Все тесты пройдены',
'verstak.platform-test.settings': 'Настройки теста платформы',
'verstak.platform-test.markdown-diagnostic': 'Диагностика Markdown платформы',
'verstak.search.searchVaultText': 'Искать текст в хранилище',
'verstak.search.vault-text': 'Поиск по тексту хранилища'
};
Object.keys(pluginStates).forEach(function (pluginId) {
var manifest = pluginStates[pluginId].manifest;
manifest.localization = {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' }
};
});
function mockPluginCatalog(pluginId, locale) {
var state = pluginStates[pluginId];
if (!state || !state.manifest) return {};
var manifest = state.manifest;
var translatedName = locale === 'ru' ? (russianPluginNames[pluginId] || manifest.name) : manifest.name;
var catalog = {
'manifest.name': translatedName,
'manifest.description': manifest.description || ''
};
var contributionFields = {
views: 'title', commands: 'title', settingsPanels: 'title', sidebarItems: 'title',
fileActions: 'label', noteActions: 'label', contextMenuEntries: 'label',
searchProviders: 'label', statusBarItems: 'label', openProviders: 'title', workspaceItems: 'title'
};
Object.keys(contributionFields).forEach(function (point) {
var field = contributionFields[point];
((manifest.contributes || {})[point] || []).forEach(function (item) {
catalog['contributions.' + point + '.' + item.id + '.' + field] = locale === 'ru'
? (russianContributionLabels[item.id] || translatedName)
: item[field];
});
});
return catalog;
}
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
vaultPluginState.enabledPlugins.push('verstak.trash');
@@ -291,7 +349,11 @@
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
vaultPluginState.enabledPlugins.push('verstak.secrets');
vaultPluginState.desiredPlugins.push({ id: 'verstak.secrets', version: '0.1.0', source: 'official' });
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
var appSettings = {
currentVaultPath: '/tmp/verstak-test/vault',
recentVaults: [],
language: localStorage.getItem('verstak-test-language') || 'system'
};
var workbenchPreferences = {};
var openedResources = [];
var pluginSettings = {
@@ -3263,6 +3325,9 @@
}
return Promise.resolve({});
},
GetPluginLocalization: function (pluginId, locale) {
return Promise.resolve([mockPluginCatalog(pluginId, locale), '']);
},
ReadPluginSettings: function (pluginId) {
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
},
@@ -3276,11 +3341,28 @@
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
return Promise.resolve([Object.assign({}, data), '']);
},
ReadPluginDataNDJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || [];
if (!Array.isArray(data) && pluginId === 'verstak.activity' && name === 'activity-events') data = [];
if (!data.length && pluginId === 'verstak.activity' && name === 'activity-events') {
data = Object.keys(pluginSettings[pluginId] || {}).filter(function (key) {
return key === 'events' || key === 'events:global' || key.indexOf('events:workspace:') === 0;
}).flatMap(function (key) {
return Array.isArray(pluginSettings[pluginId][key]) ? pluginSettings[pluginId][key] : [];
});
}
return Promise.resolve([Array.isArray(data) ? data.slice() : [], '']);
},
WritePluginDataJSON: function (pluginId, name, data) {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve('');
},
WritePluginDataNDJSON: function (pluginId, name, records) {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Array.isArray(records) ? records.slice() : [];
return Promise.resolve('');
},
OpenWorkbenchResource: function (pluginId, request) {
return openWorkbenchResource(pluginId, request || {}, '');
},
@@ -3613,6 +3695,13 @@
window.__wailsMockExternalOpens = externalOpens.slice();
return Promise.resolve('');
},
OpenExternalURL: function (pluginId, rawURL) {
var err = requirePluginPermission(pluginId, 'files.openExternal');
if (err) return Promise.resolve(err);
externalOpens.push({ action: 'url', path: String(rawURL || '') });
window.__wailsMockExternalOpens = externalOpens.slice();
return Promise.resolve('');
},
ShowVaultPathInFolder: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.openExternal');
if (err) return Promise.resolve(err);
@@ -3736,7 +3825,11 @@
OpenVault: function () { return Promise.resolve(null); },
CloseVault: function () { return Promise.resolve(null); },
SetCurrentVault: function () { return Promise.resolve(''); },
UpdateAppSettings: function () { return Promise.resolve(''); },
UpdateAppSettings: function (patch) {
appSettings = Object.assign({}, appSettings, patch || {});
if (patch && patch.language) localStorage.setItem('verstak-test-language', patch.language);
return Promise.resolve('');
},
RecordDesiredPlugin: function () { return Promise.resolve(''); },
WriteFrontendLog: function () { return Promise.resolve(); },
@@ -4066,7 +4159,8 @@
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
vaultPluginState.enabledPlugins.push('verstak.secrets');
vaultPluginState.desiredPlugins.push({ id: 'verstak.secrets', version: '0.1.0', source: 'official' });
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
localStorage.removeItem('verstak-test-language');
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [], language: 'system' };
workbenchPreferences = {};
openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
+28 -2
View File
@@ -1,7 +1,33 @@
import App from './App.svelte';
import * as Backend from '../wailsjs/go/api/App';
import { i18n } from './lib/i18n/index.js';
const app = new App({
target: document.getElementById('app'),
function unpack(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
if (result[1]) throw new Error(result[1]);
return result[0];
}
return result;
}
i18n.configure({
loadPluginCatalog: async (pluginId, locale) => unpack(await Backend.GetPluginLocalization(pluginId, locale)),
});
async function start() {
try {
const settings = await Backend.GetAppSettings();
await i18n.initialize(settings?.language || 'system');
} catch (error) {
console.error('[i18n] initialization failed:', error);
await i18n.initialize('system');
}
return new App({
target: document.getElementById('app'),
});
}
const app = start();
export default app;
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
const source = fs.readFileSync(path.resolve('frontend/src/lib/log/debug.js'), 'utf8');
const values = new Map();
const localStorage = {
getItem(key) { return values.has(key) ? values.get(key) : null; },
setItem(key, value) { values.set(key, String(value)); },
removeItem(key) { values.delete(key); },
};
const context = vm.createContext({
console: { log() {} },
localStorage,
window: { location: { search: '' } },
});
const module = new vm.SourceTextModule(source, { context, identifier: 'debug.js' });
await module.link(() => { throw new Error('debug.js must not import modules'); });
await module.evaluate();
const { debug } = module.namespace;
debug.enable({ persist: false });
assert.equal(debug.isEnabled(), true, 'session debug should enable diagnostics');
assert.equal(
localStorage.getItem('verstak-debug'),
null,
'a --debug session must not make diagnostics persist into ordinary launches',
);
console.log('debug mode smoke passed');
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import {
createI18n,
resolveLocale,
} from '../src/lib/i18n/index.js';
import englishShellCatalog from '../src/lib/i18n/catalogs/en.js';
import russianShellCatalog from '../src/lib/i18n/catalogs/ru.js';
assert.deepEqual(
Object.keys(russianShellCatalog).sort(),
Object.keys(englishShellCatalog).sort(),
'English and Russian shell catalogs must have identical keys',
);
assert.equal(resolveLocale('system', ['ru-RU']), 'ru');
assert.equal(resolveLocale('system', ['uk-UA', 'en-US']), 'en');
assert.equal(resolveLocale('ru', ['en-US']), 'ru');
assert.equal(resolveLocale('en', ['ru-RU']), 'en');
assert.throws(() => resolveLocale('de', ['de-DE']), /unsupported language/);
const catalogs = {
'localized.plugin': {
en: { 'manifest.name': 'Localized Plugin', greeting: 'Hello, {name}!' },
ru: {
'manifest.name': 'Локализованный плагин',
'contributions.views.localized.view.title': 'Локализованный экран',
greeting: 'Привет, {name}!',
},
},
};
const loads = [];
const service = createI18n({
shellCatalogs: {
en: { loading: 'Loading {name}...', fallbackOnly: 'English fallback' },
ru: { loading: 'Загрузка {name}...' },
},
systemLanguages: () => ['ru-RU'],
loadPluginCatalog: async (pluginId, locale) => {
loads.push(`${pluginId}:${locale}`);
return catalogs[pluginId]?.[locale] || {};
},
});
await service.initialize('system');
assert.equal(service.getLanguagePreference(), 'system');
assert.equal(service.getLocale(), 'ru');
assert.equal(service.t('loading', { name: 'Верстак' }), 'Загрузка Верстак...');
assert.equal(service.t('fallbackOnly'), 'English fallback');
assert.equal(service.t('missing', undefined, 'Explicit fallback'), 'Explicit fallback');
assert.equal(service.t('unknown'), 'unknown');
await service.loadPlugin('localized.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
});
assert.deepEqual(loads.sort(), ['localized.plugin:en', 'localized.plugin:ru']);
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'Мир' }), 'Привет, Мир!');
const plugin = {
manifest: {
id: 'localized.plugin',
name: 'Localized Plugin',
description: 'Literal description',
contributes: {
views: [{ id: 'localized.view', title: 'Literal View', component: 'View' }],
},
},
};
const localized = service.localizePlugin(plugin);
assert.notEqual(localized, plugin);
assert.equal(localized.manifest.name, 'Локализованный плагин');
assert.equal(localized.manifest.description, 'Literal description');
assert.equal(localized.manifest.contributes.views[0].title, 'Локализованный экран');
assert.equal(plugin.manifest.name, 'Localized Plugin');
const summary = service.localizeContributionSummary({
views: [{ pluginId: 'localized.plugin', id: 'localized.view', title: 'Literal View', component: 'View' }],
});
assert.equal(summary.views[0].title, 'Локализованный экран');
let notifications = 0;
const unsubscribe = service.subscribe(() => { notifications += 1; });
assert.equal(notifications, 1);
await service.setLanguagePreference('en');
assert.equal(service.getLocale(), 'en');
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'World' }), 'Hello, World!');
assert.equal(notifications, 2);
unsubscribe();
await service.setLanguagePreference('ru');
assert.equal(notifications, 2);
const resilientService = createI18n({
shellCatalogs: { en: { title: 'Title' }, ru: { title: 'Заголовок' } },
systemLanguages: () => ['en-US'],
loadPluginCatalog: async () => { throw new Error('broken catalog'); },
});
await resilientService.initialize('en');
await assert.rejects(
resilientService.loadPlugin('broken.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
}),
/broken catalog/,
);
let resilientNotification = '';
resilientService.subscribe((nextLocale) => { resilientNotification = nextLocale; });
await resilientService.setLanguagePreference('ru');
assert.equal(resilientService.getLanguagePreference(), 'ru');
assert.equal(resilientService.getLocale(), 'ru');
assert.equal(resilientNotification, 'ru');
assert.equal(resilientService.t('title'), 'Заголовок');
console.log('i18n service tests passed');
@@ -3,6 +3,7 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
const pluginData = {};
const scheduledNotifications = [];
globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {},
@@ -38,15 +39,40 @@ globalThis.window = {
pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve('');
},
ReplacePluginNotifications: (pluginId, items) => {
scheduledNotifications.push({ pluginId, items });
return Promise.resolve(items[0]?.id === 'rejected' ? 'notification permission denied' : '');
},
ClearPluginNotifications: (pluginId) => {
scheduledNotifications.push({ pluginId, clear: true });
return Promise.resolve('');
},
},
},
},
};
globalThis.__mockApp = window.go.api.App;
const localeListeners = new Set();
globalThis.__mockI18n = {
getLocale: () => 'ru',
translatePlugin: (_pluginId, key, params, fallback) => {
const messages = { greeting: 'Привет, {name}!' };
const message = messages[key] || fallback || key;
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
));
},
subscribe: (listener) => {
localeListeners.add(listener);
listener('ru');
return () => localeListeners.delete(listener);
},
};
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;');
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;')
.replace("import { i18n } from '../i18n/index.js';", 'const i18n = globalThis.__mockI18n;');
const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs');
fs.writeFileSync(tempPath, source);
@@ -59,6 +85,17 @@ if (!api.contributions || typeof api.contributions.list !== 'function') {
if (!api.commands || typeof api.commands.executeFor !== 'function') {
throw new Error('api.commands.executeFor is missing');
}
if (!api.i18n || typeof api.i18n.getLocale !== 'function' || typeof api.i18n.t !== 'function' || typeof api.i18n.onDidChangeLocale !== 'function') {
throw new Error('api.i18n contract is missing');
}
if (api.i18n.getLocale() !== 'ru' || api.i18n.t('greeting', { name: 'Мир' }) !== 'Привет, Мир!') {
throw new Error('api.i18n locale or translation is incorrect');
}
let localeNotifications = 0;
api.i18n.onDidChangeLocale(() => { localeNotifications += 1; });
if (localeNotifications !== 1 || localeListeners.size !== 1) {
throw new Error('api.i18n locale subscription was not registered');
}
const fileActions = await api.contributions.list('fileActions');
if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') {
@@ -88,4 +125,31 @@ if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
}
if (!api.notifications || typeof api.notifications.replace !== 'function' || typeof api.notifications.clear !== 'function') {
throw new Error('api.notifications replace/clear contract is missing');
}
await api.notifications.replace([{ id: 'reminder-1', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
await api.notifications.clear();
if (scheduledNotifications.length !== 2
|| scheduledNotifications[0].pluginId !== 'verstak.files'
|| scheduledNotifications[0].items[0].id !== 'reminder-1'
|| scheduledNotifications[1].pluginId !== 'verstak.files'
|| !scheduledNotifications[1].clear) {
throw new Error(`unexpected notification calls: ${JSON.stringify(scheduledNotifications)}`);
}
let rejected = false;
try {
await api.notifications.replace([{ id: 'rejected', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
} catch (err) {
rejected = String(err.message || err).includes('[plugin:verstak.files] notifications.replace failed: notification permission denied');
}
if (!rejected) {
throw new Error('notification backend errors must be plugin-scoped rejections');
}
api.dispose();
if (localeListeners.size !== 0) {
throw new Error('api.i18n locale subscription was not disposed');
}
console.log('plugin api contributions smoke passed');
+13
View File
@@ -0,0 +1,13 @@
import fs from 'node:fs';
import path from 'node:path';
const bindingsPath = path.resolve('frontend/wailsjs/go/api/App.js');
const bindings = fs.readFileSync(bindingsPath, 'utf8');
for (const method of ['ReplacePluginNotifications', 'ClearPluginNotifications']) {
if (!bindings.includes(`export function ${method}(`)) {
throw new Error(`Wails binding does not export ${method}`);
}
}
console.log('Wails notification bindings are present');
+33 -1
View File
@@ -1,15 +1,19 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {workspace} from '../models';
import {context} from '../models';
import {workbench} from '../models';
import {capability} from '../models';
import {api} from '../models';
import {permissions} from '../models';
import {plugin} from '../models';
import {files} from '../models';
import {notifications} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
export function ClearPluginNotifications(arg1:string):Promise<string>;
export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>;
@@ -24,6 +28,8 @@ export function DeleteVaultTrash(arg1:string,arg2:string):Promise<string>;
export function DisablePlugin(arg1:string):Promise<string>;
export function DomReady(arg1:context.Context):Promise<void>;
export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function EnablePlugin(arg1:string):Promise<string>;
@@ -48,6 +54,8 @@ export function GetPluginCapability(arg1:string,arg2:string):Promise<Record<stri
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
export function GetPluginLocalization(arg1:string,arg2:string):Promise<Record<string, string>|string>;
export function GetPlugins():Promise<Array<plugin.Plugin>>;
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
@@ -70,14 +78,18 @@ export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.File
export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenExternalURL(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>;
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
@@ -118,8 +130,14 @@ export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
export function Quit():Promise<void>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
export function ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
@@ -136,8 +154,14 @@ export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>;
export function ReplacePluginNotifications(arg1:string,arg2:Array<notifications.Request>):Promise<string>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
export function SelectDirectory():Promise<string>;
export function SelectVaultForOpen():Promise<string>;
@@ -148,8 +172,14 @@ export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function SetNotificationService(arg1:api.notificationService):Promise<void>;
export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>;
export function ShowWindow():Promise<void>;
export function Shutdown(arg1:context.Context):Promise<void>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
@@ -166,6 +196,8 @@ export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function WritePluginDataNDJSON(arg1:string,arg2:string,arg3:Array<Record<string, any>>):Promise<string>;
export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
+62 -2
View File
@@ -6,6 +6,10 @@ export function ArchiveWorkspaceNode(arg1) {
return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1);
}
export function ClearPluginNotifications(arg1) {
return window['go']['api']['App']['ClearPluginNotifications'](arg1);
}
export function CloseVault() {
return window['go']['api']['App']['CloseVault']();
}
@@ -34,6 +38,10 @@ export function DisablePlugin(arg1) {
return window['go']['api']['App']['DisablePlugin'](arg1);
}
export function DomReady(arg1) {
return window['go']['api']['App']['DomReady'](arg1);
}
export function EditWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2);
}
@@ -82,6 +90,10 @@ export function GetPluginFrontendInfo(arg1) {
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
}
export function GetPluginLocalization(arg1, arg2) {
return window['go']['api']['App']['GetPluginLocalization'](arg1, arg2);
}
export function GetPlugins() {
return window['go']['api']['App']['GetPlugins']();
}
@@ -126,14 +138,18 @@ export function ListVaultTrash(arg1) {
return window['go']['api']['App']['ListVaultTrash'](arg1);
}
export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces']();
export function ListWorkspaceIdentities() {
return window['go']['api']['App']['ListWorkspaceIdentities']();
}
export function ListWorkspaceTemplates() {
return window['go']['api']['App']['ListWorkspaceTemplates']();
}
export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces']();
}
export function MoveVaultPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4);
}
@@ -142,6 +158,10 @@ export function MoveWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2);
}
export function OpenExternalURL(arg1, arg2) {
return window['go']['api']['App']['OpenExternalURL'](arg1, arg2);
}
export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1);
}
@@ -222,10 +242,22 @@ export function PublishPluginEvent(arg1, arg2, arg3) {
return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3);
}
export function PurgeWorkspaceTrash(arg1) {
return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1);
}
export function Quit() {
return window['go']['api']['App']['Quit']();
}
export function ReadPluginDataJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
}
export function ReadPluginDataNDJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataNDJSON'](arg1, arg2);
}
export function ReadPluginSetting(arg1, arg2) {
return window['go']['api']['App']['ReadPluginSetting'](arg1, arg2);
}
@@ -258,10 +290,22 @@ export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
}
export function RepairWorkspaceIdentity(arg1, arg2) {
return window['go']['api']['App']['RepairWorkspaceIdentity'](arg1, arg2);
}
export function ReplacePluginNotifications(arg1, arg2) {
return window['go']['api']['App']['ReplacePluginNotifications'](arg1, arg2);
}
export function RestoreVaultTrash(arg1, arg2, arg3) {
return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3);
}
export function RestoreWorkspaceTrash(arg1, arg2) {
return window['go']['api']['App']['RestoreWorkspaceTrash'](arg1, arg2);
}
export function SelectDirectory() {
return window['go']['api']['App']['SelectDirectory']();
}
@@ -282,10 +326,22 @@ export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
}
export function SetNotificationService(arg1) {
return window['go']['api']['App']['SetNotificationService'](arg1);
}
export function ShowVaultPathInFolder(arg1, arg2) {
return window['go']['api']['App']['ShowVaultPathInFolder'](arg1, arg2);
}
export function ShowWindow() {
return window['go']['api']['App']['ShowWindow']();
}
export function Shutdown(arg1) {
return window['go']['api']['App']['Shutdown'](arg1);
}
export function SubscribePluginEvent(arg1, arg2) {
return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2);
}
@@ -318,6 +374,10 @@ export function WritePluginDataJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginDataJSON'](arg1, arg2, arg3);
}
export function WritePluginDataNDJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginDataNDJSON'](arg1, arg2, arg3);
}
export function WritePluginSetting(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3);
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -3,14 +3,23 @@ module github.com/verstak/verstak-desktop
go 1.24.4
require (
github.com/getlantern/systray v1.2.2
github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
@@ -22,6 +31,7 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
@@ -32,7 +42,6 @@ require (
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
+26
View File
@@ -2,10 +2,27 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -28,6 +45,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@@ -36,6 +55,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -47,6 +68,9 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@@ -67,6 +91,7 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -79,5 +104,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+803 -75
View File
File diff suppressed because it is too large Load Diff
+830 -10
View File
@@ -3,21 +3,26 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/verstak/verstak-desktop/internal/core/appsettings"
"github.com/verstak/verstak-desktop/internal/core/browserreceiver"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/notifications"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/storage"
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
@@ -25,6 +30,44 @@ import (
"github.com/verstak/verstak-desktop/internal/core/workspace"
)
type fakeNotificationScheduler struct {
replaceCalls int
clearCalls int
startCalls int
stopCalls int
pluginID string
requests []notifications.Request
onStart func()
onStop func()
}
func (s *fakeNotificationScheduler) Replace(pluginID string, requests []notifications.Request) error {
s.replaceCalls++
s.pluginID = pluginID
s.requests = append([]notifications.Request(nil), requests...)
return nil
}
func (s *fakeNotificationScheduler) Clear(pluginID string) error {
s.clearCalls++
s.pluginID = pluginID
return nil
}
func (s *fakeNotificationScheduler) Start(context.Context) {
s.startCalls++
if s.onStart != nil {
s.onStart()
}
}
func (s *fakeNotificationScheduler) Stop() {
s.stopCalls++
if s.onStop != nil {
s.onStop()
}
}
func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server {
t.Helper()
@@ -75,6 +118,18 @@ func newTestApp(tmpRoot string) *App {
}
}
func newNotificationsTestApp(manifest plugin.Manifest) (*App, *fakeNotificationScheduler) {
scheduler := &fakeNotificationScheduler{}
return &App{
plugins: []plugin.Plugin{{
Manifest: manifest,
Status: plugin.StatusLoaded,
Enabled: true,
}},
notifications: scheduler,
}, scheduler
}
func newFilesTestApp(t *testing.T, perms []string) (*App, string) {
t.Helper()
v := vault.NewVault(nil)
@@ -132,6 +187,174 @@ func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, s
return app, root
}
func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) {
requests := []notifications.Request{{ID: "reminder", DueAt: "2026-07-14T10:00:00Z", Title: "Reminder"}}
for _, manifest := range []plugin.Manifest{
{ID: "notifications.test", Permissions: []string{"notifications.schedule"}},
{ID: "notifications.test", Requires: []string{"verstak/core/notifications/v1"}},
} {
app, scheduler := newNotificationsTestApp(manifest)
if got := app.ReplacePluginNotifications(manifest.ID, requests); got == "" {
t.Fatalf("manifest %#v unexpectedly scheduled notifications", manifest)
}
if scheduler.replaceCalls != 0 {
t.Fatalf("rejected request called scheduler %d times", scheduler.replaceCalls)
}
}
}
func TestReplaceAndClearPluginNotificationsStayWithinPluginNamespace(t *testing.T) {
manifest := plugin.Manifest{
ID: "notifications.test",
Permissions: []string{"notifications.schedule"},
Requires: []string{"verstak/core/notifications/v1"},
}
app, scheduler := newNotificationsTestApp(manifest)
requests := []notifications.Request{{ID: "reminder", DueAt: "2026-07-14T10:00:00Z", Title: "Reminder"}}
if got := app.ReplacePluginNotifications(manifest.ID, requests); got != "" {
t.Fatalf("ReplacePluginNotifications error = %q", got)
}
if scheduler.replaceCalls != 1 || scheduler.pluginID != manifest.ID || len(scheduler.requests) != 1 {
t.Fatalf("scheduler state = %#v", scheduler)
}
if got := app.ClearPluginNotifications(manifest.ID); got != "" {
t.Fatalf("ClearPluginNotifications error = %q", got)
}
if scheduler.clearCalls != 1 || scheduler.pluginID != manifest.ID {
t.Fatalf("clear state = %#v", scheduler)
}
}
func TestDomReadyInitializesNotificationsBeforeScheduler(t *testing.T) {
oldInitialize := initializeNativeNotifications
defer func() { initializeNativeNotifications = oldInitialize }()
order := []string{}
initializeNativeNotifications = func(context.Context) error {
order = append(order, "initialize")
return nil
}
app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"})
scheduler.onStart = func() { order = append(order, "start") }
app.DomReady(context.Background())
if got, want := strings.Join(order, ","), "initialize,start"; got != want {
t.Fatalf("notification startup order = %q, want %q", got, want)
}
if scheduler.startCalls != 1 {
t.Fatalf("scheduler start calls = %d, want 1", scheduler.startCalls)
}
}
func TestDomReadyDoesNotStartSchedulerWhenNotificationInitializationFails(t *testing.T) {
oldInitialize := initializeNativeNotifications
defer func() { initializeNativeNotifications = oldInitialize }()
initializeNativeNotifications = func(context.Context) error { return fmt.Errorf("unavailable") }
app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"})
app.DomReady(context.Background())
if scheduler.startCalls != 0 {
t.Fatalf("scheduler start calls = %d, want 0 when native notifications are unavailable", scheduler.startCalls)
}
}
func TestShutdownStopsSchedulerBeforeCleaningUpNotifications(t *testing.T) {
oldCleanup := cleanupNativeNotifications
defer func() { cleanupNativeNotifications = oldCleanup }()
order := []string{}
cleanupNativeNotifications = func(context.Context) { order = append(order, "cleanup") }
app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"})
scheduler.onStop = func() { order = append(order, "stop") }
app.Shutdown(context.Background())
if got, want := strings.Join(order, ","), "stop,cleanup"; got != want {
t.Fatalf("notification shutdown order = %q, want %q", got, want)
}
if scheduler.stopCalls != 1 {
t.Fatalf("scheduler stop calls = %d, want 1", scheduler.stopCalls)
}
}
func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) {
oldSend := sendNativeNotification
defer func() { sendNativeNotification = oldSend }()
var gotTitle, gotBody, gotID string
sendNativeNotification = func(_ context.Context, options runtime.NotificationOptions) error {
gotID = options.ID
gotTitle = options.Title
gotBody = options.Body
return nil
}
err := NewNativeNotificationSender().Send(context.Background(), notifications.Item{
PluginID: "verstak.todo",
ID: "task-42",
Title: "Todo reminder",
Body: "Prepare alpha release",
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
if gotID != "verstak:verstak.todo:task-42" || gotTitle != "Todo reminder" || gotBody != "Prepare alpha release" {
t.Fatalf("native notification = id=%q title=%q body=%q", gotID, gotTitle, gotBody)
}
}
func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
oldHide := hideNativeWindow
defer func() { hideNativeWindow = oldHide }()
hideCalls := 0
hideNativeWindow = func(context.Context) { hideCalls++ }
app := &App{}
if prevent := app.BeforeClose(context.Background()); !prevent {
t.Fatal("BeforeClose() = false, want true while tray mode is active")
}
if hideCalls != 1 {
t.Fatalf("hide calls = %d, want 1", hideCalls)
}
}
func TestTrayQuitAllowsWindowCloseAndQuitsApplication(t *testing.T) {
oldQuit := quitNativeApplication
defer func() { quitNativeApplication = oldQuit }()
quitCalls := 0
quitNativeApplication = func(context.Context) { quitCalls++ }
app := &App{ctx: context.Background()}
app.Quit()
if quitCalls != 1 {
t.Fatalf("quit calls = %d, want 1", quitCalls)
}
if prevent := app.BeforeClose(context.Background()); prevent {
t.Fatal("BeforeClose() = true after Quit(), want false")
}
}
func TestShowWindowUsesWailsContext(t *testing.T) {
oldShow := showNativeWindow
defer func() { showNativeWindow = oldShow }()
showCalls := 0
showNativeWindow = func(context.Context) { showCalls++ }
app := &App{ctx: context.Background()}
app.ShowWindow()
if showCalls != 1 {
t.Fatalf("show calls = %d, want 1", showCalls)
}
}
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
@@ -343,6 +566,65 @@ func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
}
}
func TestGetPluginLocalizationReadsDeclaredCatalog(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "locales"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "locales", "ru.json"), []byte(`{"greeting":"Привет","count":"Всего: {count}"}`), 0o644); err != nil {
t.Fatal(err)
}
app := newTestApp(root)
app.plugins[0].Manifest.Localization = &plugin.LocalizationConfig{
DefaultLocale: "en",
Locales: map[string]string{
"en": "locales/en.json",
"ru": "locales/ru.json",
},
}
catalog, errStr := app.GetPluginLocalization("test.plugin", "ru")
if errStr != "" {
t.Fatalf("GetPluginLocalization: %s", errStr)
}
if catalog["greeting"] != "Привет" || catalog["count"] != "Всего: {count}" {
t.Fatalf("catalog = %#v", catalog)
}
}
func TestGetPluginLocalizationRejectsInvalidCatalogs(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "locales"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "locales", "bad.json"), []byte(`{"valid":"text","invalid":42}`), 0o644); err != nil {
t.Fatal(err)
}
app := newTestApp(root)
tests := []struct {
name string
config *plugin.LocalizationConfig
locale string
want string
}{
{"missing declaration", nil, "ru", "does not declare"},
{"unknown locale", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "locales/en.json"}}, "ru", "not declared"},
{"traversal", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../outside.json"}}, "en", "traversal"},
{"backslash", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\bad.json`}}, "en", "backslash"},
{"non string value", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "locales/bad.json"}}, "en", "parse"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
app.plugins[0].Manifest.Localization = tc.config
_, errStr := app.GetPluginLocalization("test.plugin", tc.locale)
if !strings.Contains(errStr, tc.want) {
t.Fatalf("error = %q, want substring %q", errStr, tc.want)
}
})
}
}
func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
@@ -532,21 +814,14 @@ func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
t.Fatalf("WriteVaultTextFile: %s", errStr)
}
settings, err := app.storage.ReadPluginSettings("verstak.activity")
stored, err := app.storage.ReadPluginDataNDJSON("verstak.activity", activityRawDataName)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings["events:workspace:Project"].([]interface{})
if !ok {
t.Fatalf("events:workspace:Project = %#v, want []interface{}", settings["events:workspace:Project"])
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(stored) != 1 {
t.Fatalf("stored %d activity events, want 1", len(stored))
}
activity, ok := stored[0].(map[string]interface{})
if !ok {
t.Fatalf("activity = %#v, want map[string]interface{}", stored[0])
}
activity := stored[0]
if activity["type"] != "file.changed" {
t.Fatalf("activity type = %#v, want file.changed", activity["type"])
}
@@ -558,6 +833,441 @@ func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
}
}
func TestBrowserActivityBatchPersistsWithoutMountedViewAndDeduplicates(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
app := &App{
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: activityPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
event := events.Event{
Name: "browser.activity.batch",
Timestamp: "2026-07-12T10:05:01Z",
Payload: map[string]interface{}{
"batchId": "batch-activity-1",
"entries": []map[string]interface{}{{
"hostname": "example.com", "startedAt": "2026-07-12T10:00:00Z", "endedAt": "2026-07-12T10:05:00Z", "durationSeconds": int64(300),
}},
},
}
if err := app.recordBrowserActivityBatch(event); err != nil {
t.Fatalf("recordBrowserActivityBatch: %v", err)
}
if err := app.recordBrowserActivityBatch(event); err != nil {
t.Fatalf("recordBrowserActivityBatch retry: %v", err)
}
records, err := app.storage.ReadPluginDataNDJSON(activityPluginID, activityRawDataName)
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 1 {
t.Fatalf("records = %+v, want one idempotent domain activity", records)
}
if records[0]["type"] != "browser.activity.domain" || records[0]["hostname"] != "example.com" {
t.Fatalf("record = %+v, want domain-only activity", records[0])
}
if _, ok := records[0]["url"]; ok {
t.Fatalf("record must not contain URL: %+v", records[0])
}
}
func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: "verstak.browser-inbox",
Name: "Browser Inbox",
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Timestamp: "2026-07-11T12:00:00Z",
Payload: map[string]interface{}{
"captureId": "capture-background",
"capturedAt": "2026-07-11T11:59:00Z",
"kind": "page",
"url": "https://example.com/article",
"title": "Background capture",
"domain": "example.com",
},
}); err != nil {
t.Fatalf("RecordBrowserCapture: %v", err)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "assign", "captureId": "capture-background", "workspaceRootPath": "Project",
}})
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "processed", "captureId": "capture-background", "processed": true,
}})
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "archive", "captureId": "capture-background",
}})
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Payload: map[string]interface{}{
"captureId": "capture-background",
"title": "Retried payload",
},
}); err != nil {
t.Fatalf("RecordBrowserCapture retry: %v", err)
}
settings, err := app.storage.ReadPluginSettings("verstak.browser-inbox")
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings["captures:global"].([]interface{})
if !ok || len(stored) != 1 {
t.Fatalf("captures:global = %#v, want one capture", settings["captures:global"])
}
capture, ok := stored[0].(map[string]interface{})
if !ok {
t.Fatalf("capture = %#v, want map[string]interface{}", stored[0])
}
if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true || capture["globalState"] != "archived" {
t.Fatalf("stored capture = %#v", capture)
}
}
func TestBrowserInboxArchiveKeepsAssignmentUntilExplicitPermanentDelete(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{
"captureId": "archive-1", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project",
}}); err != nil {
t.Fatalf("recordBrowserCapture: %v", err)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "archive", "captureId": "archive-1",
}})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
captures, _ := settings[browserInboxGlobalKey].([]interface{})
if len(captures) != 1 {
t.Fatalf("captures after archive = %#v, want retained capture", captures)
}
archived := captures[0].(map[string]interface{})
if archived["workspaceRootPath"] != "Project" || archived["globalState"] != "archived" {
t.Fatalf("archived capture = %#v, want preserved assignment", archived)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "delete", "captureId": "archive-1", "permanent": true,
}})
settings, err = app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings after delete: %v", err)
}
captures, _ = settings[browserInboxGlobalKey].([]interface{})
if len(captures) != 0 {
t.Fatalf("captures after permanent delete = %#v, want none", captures)
}
}
func TestBrowserInboxWorkspaceReferenceSurvivesRenameAndTrash(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
manager := workspace.NewManager(v.GetVaultPath())
created, err := manager.CreateWorkspace("Project", "minimal")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
workspace: manager,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{
"captureId": "workspace-ref", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project",
}}); err != nil {
t.Fatalf("recordBrowserCapture: %v", err)
}
if errStr := app.RenameWorkspace("Project", "Client"); errStr != "" {
t.Fatalf("RenameWorkspace: %s", errStr)
}
if _, errStr := app.TrashWorkspace("Client"); errStr != "" {
t.Fatalf("TrashWorkspace: %s", errStr)
}
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
capture := settings[browserInboxGlobalKey].([]interface{})[0].(map[string]interface{})
if capture["workspaceId"] != created.ID || capture["workspaceRootPath"] != "Client" || capture["workspaceState"] != "trashed" || capture["workspaceTrashId"] == "" {
t.Fatalf("workspace reference = %#v", capture)
}
}
func TestOpenExternalURLUsesBrowserOpenService(t *testing.T) {
app, _ := newFilesTestApp(t, []string{"files.openExternal"})
opened := ""
app.externalOpen = newTestExternalOpenService(func(path string) error {
opened = path
return nil
})
if errStr := app.OpenExternalURL("files.plugin", "https://example.com/path"); errStr != "" {
t.Fatalf("OpenExternalURL: %s", errStr)
}
if opened != "https://example.com/path" {
t.Fatalf("opened = %q, want URL", opened)
}
if errStr := app.OpenExternalURL("files.plugin", "ftp://example.com"); errStr == "" {
t.Fatal("OpenExternalURL accepted unsupported URL scheme")
}
}
func TestJournalHandledSessionPersistsActivityWatermark(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
contribRegistry: contribution.NewRegistry(),
plugins: []plugin.Plugin{
{Manifest: plugin.Manifest{ID: activityPluginID, Permissions: []string{"storage.namespace"}}, Status: plugin.StatusLoaded, Enabled: true},
{Manifest: plugin.Manifest{ID: "verstak.journal", Permissions: []string{"events.publish"}}, Status: plugin.StatusLoaded, Enabled: true},
},
}
app.ensureActivityProviderSubscriptions()
bus.Publish(events.Event{Name: activitySessionHandledEvent, Payload: map[string]interface{}{
"pluginId": "verstak.journal", "sessionId": "session-1", "handledThrough": "2026-07-12T10:30:00Z", "status": "accepted",
}})
settings, err := app.storage.ReadPluginSettings(activityPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
handled := settings[activitySessionHandlingKey].(map[string]interface{})
record := handled["session-1"].(map[string]interface{})
if record["status"] != "accepted" || record["handledThrough"] != "2026-07-12T10:30:00Z" {
t.Fatalf("handled record = %#v", record)
}
}
func TestBrowserInboxRejectsCaptureWithoutOpenVault(t *testing.T) {
v := vault.NewVault(nil)
app := &App{
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: browserInboxPluginID,
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Payload: map[string]interface{}{"captureId": "capture-no-vault"},
})
if err == nil || !strings.Contains(err.Error(), "unavailable") {
t.Fatalf("RecordBrowserCapture error = %v, want unavailable", err)
}
}
func TestBrowserInboxSerializesConcurrentCapturesAndMutations(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: browserInboxPluginID,
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := app.storage.WritePluginSetting(browserInboxPluginID, "domainBindings", map[string]interface{}{
"example.com": "Project",
}); err != nil {
t.Errorf("WritePluginSetting(domainBindings): %v", err)
}
}()
for i := 0; i < 50; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Timestamp: "2026-07-11T12:00:00Z",
Payload: map[string]interface{}{
"captureId": fmt.Sprintf("capture-%02d", index),
"kind": "page",
"url": fmt.Sprintf("https://example.com/%d", index),
},
}); err != nil {
t.Errorf("RecordBrowserCapture(%d): %v", index, err)
}
}(i)
}
wg.Wait()
bus.Publish(events.Event{
Name: browserInboxMutationEvent,
Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "assign",
"captureId": "capture-00",
"workspaceRootPath": "Project",
},
})
bus.Publish(events.Event{
Name: browserInboxMutationEvent,
Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "delete",
"captureId": "capture-01",
},
})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings[browserInboxGlobalKey].([]interface{})
if !ok || len(stored) != 49 {
t.Fatalf("captures:global contains %d captures, want 49", len(stored))
}
var assigned map[string]interface{}
for _, item := range stored {
capture := item.(map[string]interface{})
if capture["captureId"] == "capture-00" {
assigned = capture
}
if capture["captureId"] == "capture-01" {
t.Fatal("deleted capture remained in storage")
}
}
if assigned == nil || assigned["workspaceRootPath"] != "Project" {
t.Fatalf("assigned capture = %#v", assigned)
}
if settings["domainBindings"] == nil {
t.Fatal("concurrent domainBindings update was lost")
}
}
func TestBrowserInboxMutationMigratesAndClearsLegacyCaptures(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
if err := app.storage.WritePluginSettings(browserInboxPluginID, map[string]interface{}{
browserInboxLegacyKey: []interface{}{map[string]interface{}{
"captureId": "legacy-duplicate",
"title": "Stale legacy copy",
}},
browserInboxGlobalKey: []interface{}{map[string]interface{}{
"captureId": "legacy-duplicate",
"title": "Canonical copy",
"workspaceRootPath": "Project",
"processed": true,
}},
}); err != nil {
t.Fatalf("WritePluginSettings: %v", err)
}
app.ensureBrowserInboxSubscriptions()
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "migrate",
}})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
legacy := settings[browserInboxLegacyKey].([]interface{})
if len(legacy) != 0 {
t.Fatalf("legacy captures = %#v, want empty", legacy)
}
stored := settings[browserInboxGlobalKey].([]interface{})
if len(stored) != 1 {
t.Fatalf("canonical captures = %#v, want one", stored)
}
capture := stored[0].(map[string]interface{})
if capture["title"] != "Canonical copy" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true {
t.Fatalf("canonical capture = %#v", capture)
}
}
func TestActivityFromEventRedactsBinaryPayload(t *testing.T) {
activity := activityFromEvent(events.Event{
Name: "browser.capture.file",
@@ -1294,6 +2004,37 @@ func assertSyncState(t *testing.T, service *syncsvc.Service, wantDeviceID, wantS
}
}
func TestAppSettingsLanguageAPI(t *testing.T) {
settings := appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := settings.Load(); err != nil {
t.Fatal(err)
}
if err := settings.Update(&appsettings.Config{Theme: "light", DevMode: true}); err != nil {
t.Fatal(err)
}
app := &App{appSettings: settings, debug: true}
if got := app.GetAppSettings()["language"]; got != "system" {
t.Fatalf("initial language = %#v, want system", got)
}
if got := app.GetAppSettings()["debug"]; got != true {
t.Fatalf("debug = %#v, want true", got)
}
if errStr := app.UpdateAppSettings(map[string]interface{}{"language": "ru"}); errStr != "" {
t.Fatalf("UpdateAppSettings language: %s", errStr)
}
got := app.GetAppSettings()
if got["language"] != "ru" || got["theme"] != "light" || got["devMode"] != true {
t.Fatalf("settings after language update = %#v", got)
}
if errStr := app.UpdateAppSettings(map[string]interface{}{"language": "de"}); errStr == "" {
t.Fatal("UpdateAppSettings accepted unsupported language")
}
if got := app.GetAppSettings()["language"]; got != "ru" {
t.Fatalf("language after rejected update = %#v, want ru", got)
}
}
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent")
@@ -1493,6 +2234,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
if errStr := app.SetCurrentWorkspace("Project"); errStr != "" {
t.Fatalf("SetCurrentWorkspace: %s", errStr)
}
currentID, ok := app.GetCurrentWorkspace()["workspaceId"].(string)
if !ok || currentID == "" {
t.Fatalf("GetCurrentWorkspace workspaceId = %#v", app.GetCurrentWorkspace()["workspaceId"])
}
if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" {
t.Fatalf("RenameWorkspace: %s", errStr)
}
@@ -1503,6 +2248,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
if got := received["workspace.created"]["workspaceRootPath"]; got != "Project" {
t.Fatalf("workspace.created workspaceRootPath = %#v, want Project", got)
}
createdID, _ := received["workspace.created"]["workspaceId"].(string)
if createdID == "" {
t.Fatal("workspace.created workspaceId is empty")
}
if got := received["workspace.created"]["templateId"]; got != "client-project" {
t.Fatalf("workspace.created templateId = %#v, want client-project", got)
}
@@ -1512,17 +2261,88 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
if got := received["workspace.renamed"]["workspaceRootPath"]; got != "Renamed" {
t.Fatalf("workspace.renamed workspaceRootPath = %#v, want Renamed", got)
}
if got := received["workspace.renamed"]["workspaceId"]; got != createdID {
t.Fatalf("workspace.renamed workspaceId = %#v, want %q", got, createdID)
}
if got := received["workspace.renamed"]["previousWorkspaceRootPath"]; got != "Project" {
t.Fatalf("workspace.renamed previousWorkspaceRootPath = %#v, want Project", got)
}
if got := received["workspace.trashed"]["workspaceRootPath"]; got != "Renamed" {
t.Fatalf("workspace.trashed workspaceRootPath = %#v, want Renamed", got)
}
if got := received["workspace.trashed"]["workspaceId"]; got != createdID {
t.Fatalf("workspace.trashed workspaceId = %#v, want %q", got, createdID)
}
if got := received["workspace.trashed"]["trashPath"]; got == "" {
t.Fatalf("workspace.trashed trashPath = %#v, want non-empty", got)
}
}
func TestWorkspaceIdentityAPIListsAndRepairsDuplicates(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir)
if err := app.workspace.Load(); err != nil {
t.Fatal(err)
}
if _, errStr := app.CreateWorkspace("Original", "default"); errStr != "" {
t.Fatalf("CreateWorkspace: %s", errStr)
}
identities, errStr := app.ListWorkspaceIdentities()
if errStr != "" {
t.Fatalf("ListWorkspaceIdentities: %s", errStr)
}
var original workspace.WorkspaceIdentity
for _, identity := range identities {
if identity.RootPath == "Original" {
original = identity
break
}
}
if original.WorkspaceID == "" || original.State != "active" {
t.Fatalf("identities = %+v", identities)
}
}
func TestWorkspaceTrashRestoreAndPurgePublishIdentityLifecycle(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir)
app.eventBus = events.NewBus()
if err := app.workspace.Load(); err != nil {
t.Fatal(err)
}
received := map[string]map[string]interface{}{}
for _, eventName := range []string{"workspace.restored", "workspace.purged"} {
name := eventName
app.eventBus.Subscribe(name, func(event events.Event) {
received[name], _ = event.Payload.(map[string]interface{})
})
}
if _, errStr := app.CreateWorkspace("Client", "default"); errStr != "" {
t.Fatalf("CreateWorkspace: %s", errStr)
}
trashed, errStr := app.TrashWorkspace("Client")
if errStr != "" {
t.Fatalf("TrashWorkspace: %s", errStr)
}
restored, errStr := app.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored")
if errStr != "" {
t.Fatalf("RestoreWorkspaceTrash: %s", errStr)
}
if got := received["workspace.restored"]["workspaceId"]; got != restored.ID {
t.Fatalf("restored workspaceId = %#v, want %q", got, restored.ID)
}
trashed, errStr = app.TrashWorkspace("Client-Restored")
if errStr != "" {
t.Fatalf("TrashWorkspace restored: %s", errStr)
}
if errStr := app.PurgeWorkspaceTrash(trashed.TrashID); errStr != "" {
t.Fatalf("PurgeWorkspaceTrash: %s", errStr)
}
if got := received["workspace.purged"]["workspaceId"]; got != restored.ID {
t.Fatalf("purged workspaceId = %#v, want %q", got, restored.ID)
}
}
func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir)
+41
View File
@@ -21,6 +21,7 @@ type Config struct {
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
Language string `json:"language"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
@@ -30,6 +31,21 @@ type Config struct {
LastOpenedAt string `json:"lastOpenedAt"`
}
const (
LanguageSystem = "system"
LanguageEnglish = "en"
LanguageRussian = "ru"
)
func validLanguage(language string) bool {
switch language {
case LanguageSystem, LanguageEnglish, LanguageRussian:
return true
default:
return false
}
}
type WorkbenchPreferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
@@ -121,6 +137,9 @@ func (m *Manager) Load() error {
if cfg.Theme == "" {
cfg.Theme = "dark"
}
if !validLanguage(cfg.Language) {
cfg.Language = LanguageSystem
}
if cfg.RecentVaults == nil {
cfg.RecentVaults = []string{}
}
@@ -179,6 +198,9 @@ func (m *Manager) Update(patch *Config) error {
if patch.Theme != "" {
m.config.Theme = patch.Theme
}
if patch.Language != "" && validLanguage(patch.Language) {
m.config.Language = patch.Language
}
if patch.UserPluginsDir != "" {
m.config.UserPluginsDir = patch.UserPluginsDir
}
@@ -200,6 +222,23 @@ func (m *Manager) Update(patch *Config) error {
return m.saveLocked()
}
// UpdateLanguage validates and persists the installation-local UI language.
func (m *Manager) UpdateLanguage(language string) error {
language = strings.TrimSpace(language)
if !validLanguage(language) {
return fmt.Errorf("unsupported language %q", language)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
m.config = defaultConfig()
}
m.config.Language = language
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked()
}
// UpdateSync replaces sync settings without changing unrelated app settings.
func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
m.mu.Lock()
@@ -287,6 +326,7 @@ func defaultConfig() *Config {
CurrentVaultPath: "",
RecentVaults: []string{},
Theme: "dark",
Language: LanguageSystem,
DevMode: false,
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
Workbench: WorkbenchPreferences{},
@@ -303,6 +343,7 @@ func copyConfig(c *Config) *Config {
CurrentVaultPath: c.CurrentVaultPath,
RecentVaults: recent,
Theme: c.Theme,
Language: c.Language,
DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench,
+47
View File
@@ -25,6 +25,53 @@ func TestLoad_DefaultCreation(t *testing.T) {
if cfg.CurrentVaultPath != "" {
t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath)
}
if cfg.Language != "system" {
t.Errorf("Language: got %q, want %q", cfg.Language, "system")
}
}
func TestLoad_MissingLanguageDefaultsToSystem(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"theme":"dark","devMode":true}`), 0o600); err != nil {
t.Fatal(err)
}
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := m.Get().Language; got != "system" {
t.Fatalf("Language = %q, want system", got)
}
}
func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{Theme: "light", DevMode: true}); err != nil {
t.Fatal(err)
}
if err := m.UpdateLanguage("ru"); err != nil {
t.Fatalf("UpdateLanguage: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if cfg.Language != "ru" || cfg.Theme != "light" || !cfg.DevMode {
t.Fatalf("settings after language update = %+v", cfg)
}
if err := reloaded.UpdateLanguage("de"); err == nil {
t.Fatal("UpdateLanguage(de) succeeded, want validation error")
}
if got := reloaded.Get().Language; got != "ru" {
t.Fatalf("Language after rejected update = %q, want ru", got)
}
}
func TestLoad_CorruptConfig(t *testing.T) {
+222 -9
View File
@@ -12,18 +12,20 @@ import (
"log"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/verstak/verstak-desktop/internal/core/events"
"github.com/verstak/verstak-desktop/internal/core/hostname"
)
const (
capturePath = "/api/browser-inbox/v1/captures"
activityBatchPath = "/api/browser-activity/v1/batches"
DefaultAddr = "127.0.0.1:47731"
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
DefaultActivityURL = "http://" + DefaultAddr + activityBatchPath
receiverTokenHeader = "X-Verstak-Receiver-Token"
)
@@ -43,6 +45,9 @@ const (
maxFileTextBytes = 2 * 1024 * 1024
maxFileBytes = 8 * 1024 * 1024
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
maxActivityBodyBytes = 256 * 1024
maxActivityEntries = 100
maxActivityDuration = 10 * time.Minute
)
type Receiver struct {
@@ -55,8 +60,12 @@ type Receiver struct {
type WorkspaceProvider func() string
type Options struct {
RequireToken bool
ReceiverToken string
RequireToken bool
ReceiverToken string
Available func() bool
Persist func(events.Event) error
ActivityAvailable func() bool
PersistActivity func(events.Event) error
}
type Server struct {
@@ -105,6 +114,23 @@ type CaptureBrowser struct {
Name string `json:"name"`
}
// ActivityBatchPayload contains only domain-level time accounting. It never
// accepts or emits page URLs, titles, content, or navigation history.
type ActivityBatchPayload struct {
SchemaVersion int `json:"schemaVersion"`
BatchID string `json:"batchId"`
CreatedAt string `json:"createdAt"`
Source string `json:"source"`
Entries []ActivityEntry `json:"entries"`
}
type ActivityEntry struct {
Hostname string `json:"hostname"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
DurationSeconds int64 `json:"durationSeconds"`
}
func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver {
return NewWithOptions(bus, Options{}, providers...)
}
@@ -128,6 +154,29 @@ func (r *Receiver) SetReceiverToken(token string) {
r.options.ReceiverToken = strings.TrimSpace(token)
}
// SetPersistence configures durable capture storage and its availability gate.
func (r *Receiver) SetPersistence(available func() bool, persist func(events.Event) error) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.Available = available
r.options.Persist = persist
}
// SetActivityPersistence configures durable passive browser activity storage.
// An acknowledgement is sent only after persist returns successfully.
func (r *Receiver) SetActivityPersistence(available func() bool, persist func(events.Event) error) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.ActivityAvailable = available
r.options.PersistActivity = persist
}
func Start(addr string, receiver *Receiver) (*Server, error) {
if receiver == nil {
return nil, fmt.Errorf("receiver is required")
@@ -168,6 +217,10 @@ func (s *Server) Close() error {
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
if req.URL.Path == activityBatchPath {
r.serveActivityBatch(w, req)
return
}
if req.URL.Path != capturePath {
http.NotFound(w, req)
return
@@ -181,6 +234,11 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
options := r.currentOptions()
if options.Available != nil && !options.Available() {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
defer req.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxCaptureBodyBytes))
@@ -209,17 +267,27 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}
eventName := "browser.capture." + payload.Kind
if r.bus == nil || !r.bus.HasSubscribers(eventName) {
if options.Persist == nil && (r.bus == nil || !r.bus.HasSubscribers(eventName)) {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
eventPayload := payload.EventPayload()
r.annotateWorkspace(eventPayload)
r.bus.Publish(events.Event{
event := events.Event{
Name: eventName,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: eventPayload,
})
}
if options.Persist != nil {
if err := options.Persist(event); err != nil {
log.Printf("[browserreceiver] persist %s: %v", payload.CaptureID, err)
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
}
if r.bus != nil {
r.bus.Publish(event)
}
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
@@ -228,6 +296,68 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
})
}
func (r *Receiver) serveActivityBatch(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
return
}
if err := r.validateReceiverToken(req); err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
options := r.currentOptions()
if options.ActivityAvailable == nil || !options.ActivityAvailable() || options.PersistActivity == nil {
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
return
}
defer req.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxActivityBodyBytes))
var batch ActivityBatchPayload
if err := decoder.Decode(&batch); err != nil {
writeActivityDecodeError(w, err)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeActivityDecodeError(w, err)
return
}
if err := batch.NormalizeAndValidate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
event := events.Event{
Name: "browser.activity.batch",
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: batch.EventPayload(),
}
if err := options.PersistActivity(event); err != nil {
log.Printf("[browserreceiver] persist activity %s: %v", batch.BatchID, err)
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
return
}
if r.bus != nil {
r.bus.Publish(event)
}
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
"batchId": batch.BatchID,
})
}
func (r *Receiver) currentOptions() Options {
if r == nil {
return Options{}
}
r.optionsMu.RLock()
defer r.optionsMu.RUnlock()
return r.options
}
func (r *Receiver) validateReceiverToken(req *http.Request) error {
if r == nil {
return nil
@@ -333,6 +463,80 @@ func (p CapturePayload) Validate() error {
return nil
}
// NormalizeAndValidate validates a batch before it is made durable and replaces
// each submitted hostname with the shared canonical A-label form.
func (p *ActivityBatchPayload) NormalizeAndValidate() error {
if p == nil || p.SchemaVersion != 1 {
return fmt.Errorf("unsupported schemaVersion")
}
if strings.TrimSpace(p.BatchID) == "" {
return fmt.Errorf("batchId is required")
}
if err := validateCaptureText(p.BatchID, "batchId", maxCaptureIDBytes); err != nil {
return err
}
if strings.TrimSpace(p.CreatedAt) == "" {
return fmt.Errorf("createdAt is required")
}
if _, err := time.Parse(time.RFC3339, p.CreatedAt); err != nil {
return fmt.Errorf("createdAt is invalid")
}
if err := validateCaptureText(p.CreatedAt, "createdAt", maxCapturedAtBytes); err != nil {
return err
}
if strings.TrimSpace(p.Source) == "" {
return fmt.Errorf("source is required")
}
if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil {
return err
}
if len(p.Entries) == 0 || len(p.Entries) > maxActivityEntries {
return fmt.Errorf("entries must contain between 1 and %d items", maxActivityEntries)
}
for index := range p.Entries {
entry := &p.Entries[index]
canonical := hostname.NormalizeHostnameV1(entry.Hostname)
if canonical == "" {
return fmt.Errorf("entries[%d].hostname is invalid", index)
}
startedAt, err := time.Parse(time.RFC3339, entry.StartedAt)
if err != nil {
return fmt.Errorf("entries[%d].startedAt is invalid", index)
}
endedAt, err := time.Parse(time.RFC3339, entry.EndedAt)
if err != nil {
return fmt.Errorf("entries[%d].endedAt is invalid", index)
}
interval := endedAt.Sub(startedAt)
if interval <= 0 || interval > maxActivityDuration {
return fmt.Errorf("entries[%d] interval must be between 1 second and %s", index, maxActivityDuration)
}
if entry.DurationSeconds <= 0 || entry.DurationSeconds > int64(maxActivityDuration/time.Second) || time.Duration(entry.DurationSeconds)*time.Second > interval {
return fmt.Errorf("entries[%d].durationSeconds is invalid", index)
}
entry.Hostname = canonical
}
return nil
}
func (p ActivityBatchPayload) EventPayload() map[string]interface{} {
entries := make([]map[string]interface{}, 0, len(p.Entries))
for _, entry := range p.Entries {
entries = append(entries, map[string]interface{}{
"hostname": entry.Hostname,
"startedAt": entry.StartedAt,
"endedAt": entry.EndedAt,
"durationSeconds": entry.DurationSeconds,
})
}
return map[string]interface{}{
"batchId": strings.TrimSpace(p.BatchID),
"createdAt": strings.TrimSpace(p.CreatedAt),
"source": strings.TrimSpace(p.Source),
"entries": entries,
}
}
func (p CapturePayload) validateFile() error {
if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
return fmt.Errorf("file.name is required")
@@ -413,10 +617,19 @@ func (p CapturePayload) EventPayload() map[string]interface{} {
}
func captureDomain(rawURL, fallback string) string {
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" {
return u.Hostname()
if normalized := hostname.NormalizeURLHostnameV1(rawURL); normalized != "" {
return normalized
}
return strings.TrimSpace(fallback)
return hostname.NormalizeHostnameV1(fallback)
}
func writeActivityDecodeError(w http.ResponseWriter, err error) {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "activity payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
}
func writeError(w http.ResponseWriter, status int, message string) {
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -237,6 +238,101 @@ func TestReceiverLeavesCaptureUnassignedWithoutCurrentWorkspace(t *testing.T) {
}
}
func TestReceiverAcceptsDomainActivityBatchAfterDurablePersistence(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.activity.batch", func(event events.Event) {
received <- event
})
persisted := 0
receiver := NewWithOptions(bus, Options{
ActivityAvailable: func() bool { return true },
PersistActivity: func(event events.Event) error {
persisted++
return nil
},
})
body := `{
"schemaVersion": 1,
"batchId": "batch-123",
"createdAt": "2026-07-12T10:05:00.000Z",
"source": "verstak-browser-extension",
"entries": [{
"hostname": "пример.рф",
"startedAt": "2026-07-12T10:00:00.000Z",
"endedAt": "2026-07-12T10:05:00.000Z",
"durationSeconds": 300
}]
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
if persisted != 1 {
t.Fatalf("persisted = %d, want 1 before acknowledgement", persisted)
}
var response map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("response json: %v", err)
}
if response["batchId"] != "batch-123" || response["status"] != "accepted" {
t.Fatalf("response = %+v, want accepted batch-123", response)
}
event := <-received
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["batchId"] != "batch-123" {
t.Fatalf("event payload = %+v, want batch id", payload)
}
entries, ok := payload["entries"].([]map[string]interface{})
if !ok || len(entries) != 1 || entries[0]["hostname"] != "xn--e1afmkfd.xn--p1ai" {
t.Fatalf("event entries = %+v, want one canonical hostname", payload["entries"])
}
if _, ok := payload["url"]; ok {
t.Fatalf("activity payload must not contain URL: %+v", payload)
}
}
func TestReceiverRejectsDomainActivityWithoutConsumerOrValidInterval(t *testing.T) {
for _, tc := range []struct {
name string
receiver *Receiver
body string
wantCode int
}{
{
name: "consumer unavailable",
receiver: New(events.NewBus()),
body: `{"schemaVersion":1,"batchId":"batch-a","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:05:00Z","durationSeconds":300}]}`,
wantCode: http.StatusServiceUnavailable,
},
{
name: "duration too long",
receiver: NewWithOptions(events.NewBus(), Options{
ActivityAvailable: func() bool { return true },
PersistActivity: func(events.Event) error { return nil },
}),
body: `{"schemaVersion":1,"batchId":"batch-b","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:20:01Z","durationSeconds":1201}]}`,
wantCode: http.StatusBadRequest,
},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", strings.NewReader(tc.body))
rec := httptest.NewRecorder()
tc.receiver.ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.wantCode, rec.Body.String())
}
})
}
}
func TestReceiverRequiresTokenWhenPaired(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
@@ -321,6 +417,61 @@ func TestReceiverAcceptsPairedToken(t *testing.T) {
}
}
func TestReceiverRejectsCaptureWhenInboxIsUnavailable(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {
t.Fatalf("unexpected event while inbox is unavailable: %#v", event)
})
receiver := NewWithOptions(bus, Options{
Available: func() bool { return false },
})
req := httptest.NewRequest(http.MethodPost, capturePath, strings.NewReader(`{
"schemaVersion": 1,
"captureId": "capture-no-vault",
"capturedAt": "2026-07-11T12:00:00Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`))
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusServiceUnavailable, res.Body.String())
}
}
func TestReceiverDoesNotAcknowledgeFailedPersistence(t *testing.T) {
bus := events.NewBus()
published := false
bus.Subscribe("browser.capture.page", func(event events.Event) {
published = true
})
receiver := NewWithOptions(bus, Options{
Available: func() bool { return true },
Persist: func(event events.Event) error {
return errors.New("disk full")
},
})
req := httptest.NewRequest(http.MethodPost, capturePath, strings.NewReader(`{
"schemaVersion": 1,
"captureId": "capture-disk-full",
"capturedAt": "2026-07-11T12:00:00Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`))
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusServiceUnavailable, res.Body.String())
}
if published {
t.Fatal("capture event was published after persistence failed")
}
}
func TestReceiverRotatesPairedToken(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {})
+123
View File
@@ -0,0 +1,123 @@
package hostname
import (
"net/netip"
"net/url"
"strings"
"unicode"
"golang.org/x/net/idna"
)
const (
maxDNSHostnameLength = 253
maxDNSLabelLength = 63
)
// NormalizeHostnameV1 returns the canonical A-label hostname used by domain
// bindings. It accepts a bare DNS name, IPv4 address, bracketed IPv6 literal,
// localhost, or a single-label internal hostname. Invalid input returns "".
func NormalizeHostnameV1(input string) string {
value := strings.TrimSpace(input)
if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "\\/?#@") {
return ""
}
if strings.HasPrefix(value, "[") || strings.HasSuffix(value, "]") {
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
return ""
}
return normalizeIPv6(value[1 : len(value)-1])
}
if strings.Contains(value, ":") {
return ""
}
value = strings.TrimSuffix(value, ".")
if value == "" || strings.HasSuffix(value, ".") {
return ""
}
if isNumericHostname(value) {
return normalizeIPv4(value)
}
return normalizeDNS(value)
}
// NormalizeURLHostnameV1 returns the canonical hostname from an HTTP(S) URL.
// Ports, paths, credentials, and fragments are intentionally not preserved.
func NormalizeURLHostnameV1(input string) string {
value := strings.TrimSpace(input)
if value == "" {
return ""
}
parsed, err := url.ParseRequestURI(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return ""
}
host := parsed.Hostname()
if host == "" {
return ""
}
if address, err := netip.ParseAddr(host); err == nil && address.Is6() {
return address.String()
}
return NormalizeHostnameV1(host)
}
func normalizeIPv4(value string) string {
address, err := netip.ParseAddr(value)
if err != nil || !address.Is4() || address.String() != value {
return ""
}
return address.String()
}
func normalizeIPv6(value string) string {
address, err := netip.ParseAddr(value)
if err != nil || !address.Is6() {
return ""
}
return address.String()
}
func normalizeDNS(value string) string {
ascii, err := idna.Lookup.ToASCII(value)
if err != nil {
return ""
}
ascii = strings.ToLower(strings.TrimSuffix(ascii, "."))
if ascii == "" || strings.HasSuffix(ascii, ".") || len(ascii) > maxDNSHostnameLength {
return ""
}
for _, label := range strings.Split(ascii, ".") {
if len(label) == 0 || len(label) > maxDNSLabelLength || !isDNSLabel(label) {
return ""
}
}
return ascii
}
func isNumericHostname(value string) bool {
for _, char := range value {
if (char < '0' || char > '9') && char != '.' {
return false
}
}
return true
}
func isDNSLabel(label string) bool {
if !isASCIIAlphaNum(label[0]) || !isASCIIAlphaNum(label[len(label)-1]) {
return false
}
for _, char := range label {
if !isASCIIAlphaNum(byte(char)) && char != '-' {
return false
}
}
return true
}
func isASCIIAlphaNum(char byte) bool {
return char >= 'a' && char <= 'z' || char >= '0' && char <= '9'
}
+50
View File
@@ -0,0 +1,50 @@
package hostname
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
type normalizationVector struct {
Input string `json:"input"`
Output string `json:"output"`
}
type normalizationVectors struct {
Bare []normalizationVector `json:"bare"`
URL []normalizationVector `json:"url"`
}
func TestNormalizeHostnameV1Vectors(t *testing.T) {
vectors := loadVectors(t)
for _, vector := range vectors.Bare {
if got := NormalizeHostnameV1(vector.Input); got != vector.Output {
t.Errorf("NormalizeHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output)
}
}
}
func TestNormalizeURLHostnameV1Vectors(t *testing.T) {
vectors := loadVectors(t)
for _, vector := range vectors.URL {
if got := NormalizeURLHostnameV1(vector.Input); got != vector.Output {
t.Errorf("NormalizeURLHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output)
}
}
}
func loadVectors(t *testing.T) normalizationVectors {
t.Helper()
path := filepath.Join("testdata", "hostname-normalization-v1.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var vectors normalizationVectors
if err := json.Unmarshal(data, &vectors); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return vectors
}
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/mirivlad/verstak-sdk/main/schemas/hostname-normalization-v1.json",
"title": "Verstak canonical hostname normalization v1 test vectors",
"description": "Canonical hostnames are lowercase ASCII A-labels without a port or trailing DNS dot. Bare hostnames accept DNS names, IPv4, bracketed IPv6, localhost, and internal single-label names. URL inputs accept only HTTP(S). Invalid or excessively long input normalizes to an empty string.",
"version": 1,
"bare": [
{ "input": "example.com", "output": "example.com" },
{ "input": " Example.COM. ", "output": "example.com" },
{ "input": "пример.рф", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "bücher.example", "output": "xn--bcher-kva.example" },
{ "input": "127.0.0.1", "output": "127.0.0.1" },
{ "input": "[2001:db8::1]", "output": "2001:db8::1" },
{ "input": "localhost", "output": "localhost" },
{ "input": "intranet", "output": "intranet" },
{ "input": "", "output": "" },
{ "input": "https://example.com", "output": "" },
{ "input": "example.com:443", "output": "" },
{ "input": "user@example.com", "output": "" },
{ "input": "bad host", "output": "" },
{ "input": "example..com", "output": "" },
{ "input": "example.com..", "output": "" },
{ "input": "127.000.000.001", "output": "" },
{ "input": "[2001:db8::1]:443", "output": "" },
{ "input": "a...............................................................example", "output": "" }
],
"url": [
{ "input": "https://пример.рф/path", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "http://Example.COM.:8080/path", "output": "example.com" },
{ "input": "https://[2001:db8::1]/", "output": "2001:db8::1" },
{ "input": "ftp://example.com/path", "output": "" },
{ "input": "not a URL", "output": "" }
]
}
+254
View File
@@ -0,0 +1,254 @@
// Package notifications persists plugin-owned reminder schedules and delivers
// due entries through a desktop-native sender.
package notifications
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
)
const (
maxRequestsPerPlugin = 500
maxIDLength = 256
maxTitleLength = 512
maxBodyLength = 4096
)
// VaultPath provides a stable operation while a vault is open.
type VaultPath interface {
WithOpenPath(func(string) error) error
}
// Request is a desired scheduled notification in a plugin's own namespace.
type Request struct {
ID string `json:"id"`
DueAt string `json:"dueAt"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
// Item is the persisted form of a plugin notification schedule.
type Item struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
DueAt string `json:"dueAt"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
SentForDueAt string `json:"sentForDueAt,omitempty"`
}
// Sender delivers one native desktop notification.
type Sender interface {
Send(context.Context, Item) error
}
// Manager owns canonical schedules for every authorized plugin.
type Manager struct {
mu sync.Mutex
vault VaultPath
sender Sender
now func() time.Time
items []Item
loaded bool
stop chan struct{}
}
// New creates a notification manager. The supplied clock is used by tests; a
// nil clock uses the current time.
func New(vault VaultPath, sender Sender, now func() time.Time) *Manager {
if now == nil {
now = time.Now
}
return &Manager{vault: vault, sender: sender, now: now}
}
// Replace atomically replaces one plugin's desired schedules.
func (m *Manager) Replace(pluginID string, requests []Request) error {
if err := validateRequests(pluginID, requests); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return err
}
previous := make(map[string]Item)
kept := make([]Item, 0, len(m.items)+len(requests))
for _, item := range m.items {
if item.PluginID == pluginID {
previous[item.ID] = item
continue
}
kept = append(kept, item)
}
for _, request := range requests {
item := Item{
PluginID: pluginID,
ID: request.ID,
DueAt: request.DueAt,
Title: request.Title,
Body: request.Body,
}
if old, ok := previous[request.ID]; ok && old.DueAt == request.DueAt {
item.SentForDueAt = old.SentForDueAt
}
kept = append(kept, item)
}
sortItems(kept)
if err := m.writeLocked(kept); err != nil {
return err
}
m.items = kept
return nil
}
// Clear removes every schedule owned by a plugin.
func (m *Manager) Clear(pluginID string) error {
return m.Replace(pluginID, nil)
}
// Items returns a snapshot of the current schedule state.
func (m *Manager) Items() []Item {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return nil
}
return append([]Item(nil), m.items...)
}
// Path reports the canonical schedule path while the vault is open.
func (m *Manager) Path() string {
if m == nil || m.vault == nil {
return ""
}
var path string
if err := m.vault.WithOpenPath(func(vaultPath string) error {
path = schedulePath(vaultPath)
return nil
}); err != nil {
return ""
}
return path
}
// Start immediately checks due schedules, then repeats every 30 seconds.
func (m *Manager) Start(ctx context.Context) {
if m == nil {
return
}
m.mu.Lock()
if m.stop != nil {
m.mu.Unlock()
return
}
stop := make(chan struct{})
m.stop = stop
m.mu.Unlock()
go func() {
m.Tick(ctx)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.Tick(ctx)
case <-stop:
return
case <-ctx.Done():
return
}
}
}()
}
// Stop stops a running scheduler. It is safe to call repeatedly.
func (m *Manager) Stop() {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.stop == nil {
return
}
close(m.stop)
m.stop = nil
}
// Tick delivers each overdue schedule once. A sender failure leaves the item
// pending for a later tick.
func (m *Manager) Tick(ctx context.Context) {
if m == nil || m.sender == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return
}
now := m.now().UTC()
for index, item := range m.items {
dueAt, err := time.Parse(time.RFC3339, item.DueAt)
if err != nil || dueAt.After(now) || item.SentForDueAt == item.DueAt {
continue
}
if err := m.sender.Send(ctx, item); err != nil {
continue
}
updated := append([]Item(nil), m.items...)
updated[index].SentForDueAt = item.DueAt
if err := m.writeLocked(updated); err != nil {
continue
}
m.items = updated
}
}
func validateRequests(pluginID string, requests []Request) error {
if strings.TrimSpace(pluginID) == "" || strings.ContainsAny(pluginID, "/\\") {
return fmt.Errorf("invalid plugin ID %q", pluginID)
}
if len(requests) > maxRequestsPerPlugin {
return fmt.Errorf("plugin %q has more than %d notification schedules", pluginID, maxRequestsPerPlugin)
}
seen := make(map[string]bool, len(requests))
for _, request := range requests {
if request.ID == "" || len(request.ID) > maxIDLength {
return fmt.Errorf("notification ID is empty or too long")
}
if seen[request.ID] {
return fmt.Errorf("duplicate notification ID %q", request.ID)
}
seen[request.ID] = true
if !strings.HasSuffix(request.DueAt, "Z") {
return fmt.Errorf("notification %q dueAt must be UTC RFC3339", request.ID)
}
if _, err := time.Parse(time.RFC3339, request.DueAt); err != nil {
return fmt.Errorf("notification %q has invalid dueAt: %w", request.ID, err)
}
if strings.TrimSpace(request.Title) == "" || len(request.Title) > maxTitleLength {
return fmt.Errorf("notification %q title is empty or too long", request.ID)
}
if len(request.Body) > maxBodyLength {
return fmt.Errorf("notification %q body is too long", request.ID)
}
}
return nil
}
func sortItems(items []Item) {
sort.Slice(items, func(left, right int) bool {
if items[left].PluginID != items[right].PluginID {
return items[left].PluginID < items[right].PluginID
}
return items[left].ID < items[right].ID
})
}
+131
View File
@@ -0,0 +1,131 @@
package notifications
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
type testVault struct {
path string
}
func (v testVault) WithOpenPath(operation func(string) error) error {
return operation(v.path)
}
type fakeSender struct {
err error
items []Item
}
func (s *fakeSender) Send(_ context.Context, item Item) error {
s.items = append(s.items, item)
return s.err
}
func testManager(t *testing.T, sender *fakeSender, now time.Time) *Manager {
t.Helper()
return New(testVault{path: t.TempDir()}, sender, func() time.Time { return now })
}
func request(id, dueAt string) Request {
return Request{ID: id, DueAt: dueAt, Title: "Reminder " + id, Body: "Todo " + id}
}
func TestReplaceRemovesStalePluginSchedules(t *testing.T) {
manager := testManager(t, &fakeSender{}, time.Date(2026, 7, 14, 9, 0, 0, 0, time.UTC))
if err := manager.Replace("verstak.todo", []Request{
request("first", "2026-07-14T10:00:00Z"),
request("second", "2026-07-14T11:00:00Z"),
}); err != nil {
t.Fatalf("initial replace: %v", err)
}
if err := manager.Replace("verstak.todo", []Request{request("second", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("stale-item replace: %v", err)
}
items := manager.Items()
if len(items) != 1 || items[0].PluginID != "verstak.todo" || items[0].ID != "second" {
t.Fatalf("items = %#v, want only second Todo reminder", items)
}
}
func TestReplaceReschedulesDeliveredItem(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
sender := &fakeSender{}
manager := testManager(t, sender, now)
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("initial replace: %v", err)
}
manager.Tick(context.Background())
if len(sender.items) != 1 || manager.Items()[0].SentForDueAt == "" {
t.Fatalf("first delivery = %#v, state = %#v", sender.items, manager.Items())
}
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T13:00:00Z")}); err != nil {
t.Fatalf("reschedule: %v", err)
}
if got := manager.Items()[0].SentForDueAt; got != "" {
t.Fatalf("rescheduled item kept old acknowledgment %q", got)
}
}
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
sender := &fakeSender{err: errors.New("notification unavailable")}
manager := testManager(t, sender, now)
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("replace: %v", err)
}
manager.Tick(context.Background())
if len(sender.items) != 1 || manager.Items()[0].SentForDueAt != "" {
t.Fatalf("failed send was acknowledged: sent=%d items=%#v", len(sender.items), manager.Items())
}
sender.err = nil
manager.Tick(context.Background())
manager.Tick(context.Background())
if len(sender.items) != 2 {
t.Fatalf("send attempts = %d, want 2", len(sender.items))
}
if got := manager.Items()[0].SentForDueAt; got != "2026-07-14T11:00:00Z" {
t.Fatalf("sent acknowledgment = %q", got)
}
}
func TestPersistentSchedulesReloadAndDeliverOverdueOnlyOnce(t *testing.T) {
path := t.TempDir()
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
first := New(testVault{path: path}, &fakeSender{}, func() time.Time { return now })
if err := first.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("replace: %v", err)
}
if got, want := filepath.Join(path, ".verstak", "notifications", "schedules.json"), first.Path(); got != want {
t.Fatalf("schedule path = %q, want %q", got, want)
}
sender := &fakeSender{}
restarted := New(testVault{path: path}, sender, func() time.Time { return now })
restarted.Tick(context.Background())
restarted.Tick(context.Background())
if len(sender.items) != 1 || sender.items[0].ID != "todo-1" {
t.Fatalf("overdue delivery = %#v", sender.items)
}
}
func TestReplaceRejectsInvalidRequests(t *testing.T) {
manager := testManager(t, &fakeSender{}, time.Now().UTC())
for _, requests := range [][]Request{
{{ID: "", DueAt: "2026-07-14T10:00:00Z", Title: "title"}},
{{ID: "same", DueAt: "2026-07-14T10:00:00Z", Title: "title"}, {ID: "same", DueAt: "2026-07-14T11:00:00Z", Title: "title"}},
{{ID: "bad-date", DueAt: "not-a-date", Title: "title"}},
} {
if err := manager.Replace("verstak.todo", requests); err == nil {
t.Fatalf("Replace(%#v) succeeded", requests)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package notifications
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
type storedSchedules struct {
Items []Item `json:"items"`
}
func schedulePath(vaultPath string) string {
return filepath.Join(vaultPath, ".verstak", "notifications", "schedules.json")
}
func (m *Manager) loadLocked() error {
if m.loaded {
return nil
}
if m.vault == nil {
return fmt.Errorf("notification vault is not initialized")
}
var loaded []Item
if err := m.vault.WithOpenPath(func(vaultPath string) error {
path := schedulePath(vaultPath)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
loaded = []Item{}
return nil
}
if err != nil {
return fmt.Errorf("read notification schedules: %w", err)
}
var stored storedSchedules
if err := json.Unmarshal(data, &stored); err != nil {
return fmt.Errorf("decode notification schedules: %w", err)
}
for _, item := range stored.Items {
if err := validateRequests(item.PluginID, []Request{{ID: item.ID, DueAt: item.DueAt, Title: item.Title, Body: item.Body}}); err != nil {
return fmt.Errorf("invalid stored notification schedule: %w", err)
}
loaded = append(loaded, item)
}
sortItems(loaded)
return nil
}); err != nil {
return err
}
m.items = loaded
m.loaded = true
return nil
}
func (m *Manager) writeLocked(items []Item) error {
if m.vault == nil {
return fmt.Errorf("notification vault is not initialized")
}
return m.vault.WithOpenPath(func(vaultPath string) error {
data, err := json.MarshalIndent(storedSchedules{Items: items}, "", " ")
if err != nil {
return fmt.Errorf("encode notification schedules: %w", err)
}
path := schedulePath(vaultPath)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create notification schedule directory: %w", err)
}
temporary := filepath.Join(filepath.Dir(path), fmt.Sprintf(".schedules.%d.tmp", time.Now().UnixNano()))
if err := os.WriteFile(temporary, data, 0o644); err != nil {
return fmt.Errorf("write notification schedules: %w", err)
}
if err := os.Rename(temporary, path); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("replace notification schedules: %w", err)
}
return nil
})
}
+1
View File
@@ -39,6 +39,7 @@ func (r *Registry) registerDefaults() {
{Name: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true},
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
{Name: "notifications.schedule", Description: "Schedule native notifications in the plugin's own namespace", Dangerous: false},
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
{Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false},
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
+69 -17
View File
@@ -7,28 +7,36 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"strings"
)
// Manifest represents a Verstak plugin.json manifest.
type Manifest struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Icon string `json:"icon,omitempty"`
Provides []string `json:"provides"`
Requires []string `json:"requires,omitempty"`
OptionalRequires []string `json:"optionalRequires,omitempty"`
Permissions []string `json:"permissions"`
Frontend *FrontendConfig `json:"frontend,omitempty"`
Backend *BackendConfig `json:"backend,omitempty"`
Migrations *MigrationConfig `json:"migrations,omitempty"`
Contributes *Contributions `json:"contributes,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"`
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Icon string `json:"icon,omitempty"`
Localization *LocalizationConfig `json:"localization,omitempty"`
Provides []string `json:"provides"`
Requires []string `json:"requires,omitempty"`
OptionalRequires []string `json:"optionalRequires,omitempty"`
Permissions []string `json:"permissions"`
Frontend *FrontendConfig `json:"frontend,omitempty"`
Backend *BackendConfig `json:"backend,omitempty"`
Migrations *MigrationConfig `json:"migrations,omitempty"`
Contributes *Contributions `json:"contributes,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"`
}
// LocalizationConfig declares plugin-owned UI message catalogs.
type LocalizationConfig struct {
DefaultLocale string `json:"defaultLocale"`
Locales map[string]string `json:"locales"`
}
// FrontendConfig describes the plugin's frontend bundle.
@@ -237,6 +245,25 @@ func ValidateManifest(m *Manifest) []string {
if len(m.Permissions) == 0 {
errs.add("permissions must have at least one permission")
}
if m.Localization != nil {
if !isValidLocaleTag(m.Localization.DefaultLocale) {
errs.add("localization.defaultLocale %q is not a valid lower-case locale", m.Localization.DefaultLocale)
}
if len(m.Localization.Locales) == 0 {
errs.add("localization.locales must contain at least one catalog")
}
if _, ok := m.Localization.Locales[m.Localization.DefaultLocale]; !ok {
errs.add("localization.defaultLocale %q must have a declared catalog", m.Localization.DefaultLocale)
}
for locale, catalogPath := range m.Localization.Locales {
if !isValidLocaleTag(locale) {
errs.add("localization locale %q is not a valid lower-case locale", locale)
}
if err := ValidateLocalizationPath(catalogPath); err != nil {
errs.add("localization locale %q: %v", locale, err)
}
}
}
if m.Contributes != nil {
for i, provider := range m.Contributes.OpenProviders {
if provider.ID == "" {
@@ -262,6 +289,31 @@ func ValidateManifest(m *Manifest) []string {
return errs.errors
}
var localeTagPattern = regexp.MustCompile(`^[a-z]{2}(?:-[a-z0-9]+)*$`)
func isValidLocaleTag(locale string) bool {
return localeTagPattern.MatchString(locale)
}
// ValidateLocalizationPath accepts only slash-separated plugin-relative paths.
func ValidateLocalizationPath(catalogPath string) error {
if strings.TrimSpace(catalogPath) == "" {
return fmt.Errorf("catalog path is required")
}
if filepath.IsAbs(catalogPath) || filepath.VolumeName(catalogPath) != "" || strings.HasPrefix(catalogPath, "/") {
return fmt.Errorf("catalog path must be relative")
}
if strings.Contains(catalogPath, `\`) {
return fmt.Errorf("catalog path must not contain backslash")
}
for _, part := range strings.Split(catalogPath, "/") {
if part == ".." {
return fmt.Errorf("catalog path traversal is not allowed")
}
}
return nil
}
func isValidPluginID(id string) bool {
if id == "" {
return false
+44
View File
@@ -233,6 +233,50 @@ func TestValidateManifest_OpenProviders(t *testing.T) {
}
}
func TestValidateManifest_Localization(t *testing.T) {
base := &Manifest{
SchemaVersion: 1,
ID: "localized.plugin",
Name: "Localized",
Version: "1.0.0",
APIVersion: "1.0",
Provides: []string{"localized.capability"},
Permissions: []string{"ui.register"},
Localization: &LocalizationConfig{
DefaultLocale: "en",
Locales: map[string]string{
"en": "locales/en.json",
"ru": "locales/ru.json",
},
},
}
if errs := ValidateManifest(base); len(errs) != 0 {
t.Fatalf("valid localization errors = %v", errs)
}
tests := []struct {
name string
localization *LocalizationConfig
want string
}{
{"missing default catalog", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"ru": "locales/ru.json"}}, "defaultLocale"},
{"invalid locale", &LocalizationConfig{DefaultLocale: "EN", Locales: map[string]string{"EN": "locales/en.json"}}, "locale"},
{"absolute path", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "/tmp/en.json"}}, "relative"},
{"traversal", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../en.json"}}, "traversal"},
{"backslash", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\en.json`}}, "backslash"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
manifest := *base
manifest.Localization = tc.localization
combined := strings.Join(ValidateManifest(&manifest), "\n")
if !strings.Contains(combined, tc.want) {
t.Fatalf("errors = %q, want %q", combined, tc.want)
}
})
}
}
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
dir1 := t.TempDir()
+260 -10
View File
@@ -20,6 +20,17 @@ type Storage struct {
vault *vault.Vault
}
// NDJSONRetention bounds append-only plugin data. Records are compacted after
// a successful append so settings.json never becomes an event log.
type NDJSONRetention struct {
TimestampField string
MaxAge time.Duration
MaxEntries int
MaxBytes int64
DeduplicateField string
DeduplicateValue string
}
// New creates a new Storage instance backed by the given vault.
func New(v *vault.Vault) *Storage {
return &Storage{vault: v}
@@ -84,13 +95,22 @@ func atomicWrite(path string, data []byte) error {
// ReadPluginSettings reads all settings for a plugin.
// Returns empty map if settings.json does not exist.
func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
var result map[string]interface{}
err := s.withOpenVault(func(vaultPath string) error {
var err error
result, err = readPluginSettingsAt(vaultPath, pluginID)
return err
})
return result, err
}
dir := s.vault.GetPluginSettingsPath(pluginID)
path := filepath.Join(dir, "settings.json")
func readPluginSettingsAt(vaultPath, pluginID string) (map[string]interface{}, error) {
path := filepath.Join(vaultPath, ".verstak", "plugin-settings", pluginID, "settings.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@@ -108,13 +128,18 @@ func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, e
// WritePluginSettings writes all settings for a plugin atomically.
func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := validatePluginID(pluginID); err != nil {
return err
}
return s.withOpenVault(func(vaultPath string) error {
return writePluginSettingsAt(vaultPath, pluginID, data)
})
}
dir := s.vault.GetPluginSettingsPath(pluginID)
path := filepath.Join(dir, "settings.json")
func writePluginSettingsAt(vaultPath, pluginID string, data map[string]interface{}) error {
path := filepath.Join(vaultPath, ".verstak", "plugin-settings", pluginID, "settings.json")
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err)
@@ -137,12 +162,39 @@ func (s *Storage) ReadPluginSetting(pluginID, key string) (interface{}, error) {
// WritePluginSetting writes a single setting key.
func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error {
settings, err := s.ReadPluginSettings(pluginID)
if err != nil {
return s.UpdatePluginSettings(pluginID, func(settings map[string]interface{}) error {
settings[key] = value
return nil
})
}
// UpdatePluginSettings atomically reads, mutates, and writes a plugin's settings.
func (s *Storage) UpdatePluginSettings(pluginID string, update func(map[string]interface{}) error) error {
if update == nil {
return fmt.Errorf("settings update is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if err := validatePluginID(pluginID); err != nil {
return err
}
settings[key] = value
return s.WritePluginSettings(pluginID, settings)
return s.withOpenVault(func(vaultPath string) error {
settings, err := readPluginSettingsAt(vaultPath, pluginID)
if err != nil {
return err
}
if err := update(settings); err != nil {
return err
}
return writePluginSettingsAt(vaultPath, pluginID, settings)
})
}
func (s *Storage) withOpenVault(operation func(string) error) error {
if s == nil || s.vault == nil {
return fmt.Errorf("vault is not initialized")
}
return s.vault.WithOpenPath(operation)
}
// ─── Data JSON API ────────────────────────────────────────
@@ -193,6 +245,204 @@ func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]int
return atomicWrite(path, encoded)
}
// ReadPluginDataNDJSON reads an append-only named data file. A missing file is
// represented by an empty slice.
func (s *Storage) ReadPluginDataNDJSON(pluginID, name string) ([]map[string]interface{}, error) {
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
if err := validateStorageName("data", name); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
var records []map[string]interface{}
err := s.withOpenVault(func(vaultPath string) error {
var err error
records, err = readPluginDataNDJSONAt(vaultPath, pluginID, name)
return err
})
return records, err
}
// AppendPluginDataNDJSON appends records durably and then applies bounded
// retention. It returns false without writing when the supplied idempotency
// value is already present in the retained log.
func (s *Storage) AppendPluginDataNDJSON(pluginID, name string, records []map[string]interface{}, retention NDJSONRetention) (bool, error) {
if err := validatePluginID(pluginID); err != nil {
return false, err
}
if err := validateStorageName("data", name); err != nil {
return false, err
}
if len(records) == 0 {
return false, fmt.Errorf("NDJSON records are empty")
}
if retention.MaxEntries < 0 || retention.MaxBytes < 0 || retention.MaxAge < 0 {
return false, fmt.Errorf("NDJSON retention values must not be negative")
}
s.mu.Lock()
defer s.mu.Unlock()
stored := false
err := s.withOpenVault(func(vaultPath string) error {
existing, err := readPluginDataNDJSONAt(vaultPath, pluginID, name)
if err != nil {
return err
}
if retention.DeduplicateField != "" && retention.DeduplicateValue != "" {
for _, record := range existing {
if fmt.Sprint(record[retention.DeduplicateField]) == retention.DeduplicateValue {
return nil
}
}
}
path := pluginDataNDJSONPath(vaultPath, pluginID, name)
if err := appendNDJSON(path, records); err != nil {
return err
}
stored = true
compacted := compactNDJSONRecords(append(existing, records...), retention, time.Now().UTC())
if !sameNDJSONRecords(append(existing, records...), compacted) {
return writeNDJSON(path, compacted)
}
return nil
})
return stored, err
}
// WritePluginDataNDJSON replaces a named append-only data file. It is reserved
// for explicit user actions such as clearing Activity, never normal event
// ingestion.
func (s *Storage) WritePluginDataNDJSON(pluginID, name string, records []map[string]interface{}) error {
if err := validatePluginID(pluginID); err != nil {
return err
}
if err := validateStorageName("data", name); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
return s.withOpenVault(func(vaultPath string) error {
return writeNDJSON(pluginDataNDJSONPath(vaultPath, pluginID, name), records)
})
}
func pluginDataNDJSONPath(vaultPath, pluginID, name string) string {
return filepath.Join(vaultPath, ".verstak", "plugin-data", pluginID, name+".ndjson")
}
func readPluginDataNDJSONAt(vaultPath, pluginID, name string) ([]map[string]interface{}, error) {
data, err := os.ReadFile(pluginDataNDJSONPath(vaultPath, pluginID, name))
if err != nil {
if os.IsNotExist(err) {
return []map[string]interface{}{}, nil
}
return nil, fmt.Errorf("failed to read NDJSON data %s for plugin %s: %w", name, pluginID, err)
}
if len(data) == 0 {
return []map[string]interface{}{}, nil
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
records := make([]map[string]interface{}, 0, len(lines))
for index, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var record map[string]interface{}
if err := json.Unmarshal([]byte(line), &record); err != nil {
return nil, fmt.Errorf("corrupt NDJSON data %s line %d for plugin %s: %w", name, index+1, pluginID, err)
}
records = append(records, record)
}
return records, nil
}
func appendNDJSON(path string, records []map[string]interface{}) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create NDJSON data directory: %w", err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("failed to open NDJSON data file: %w", err)
}
defer file.Close()
for _, record := range records {
encoded, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("failed to marshal NDJSON record: %w", err)
}
if _, err := file.Write(append(encoded, '\n')); err != nil {
return fmt.Errorf("failed to append NDJSON record: %w", err)
}
}
if err := file.Sync(); err != nil {
return fmt.Errorf("failed to sync NDJSON data: %w", err)
}
return nil
}
func writeNDJSON(path string, records []map[string]interface{}) error {
data := make([]byte, 0)
for _, record := range records {
encoded, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("failed to marshal NDJSON record: %w", err)
}
data = append(data, encoded...)
data = append(data, '\n')
}
return atomicWrite(path, data)
}
func compactNDJSONRecords(records []map[string]interface{}, retention NDJSONRetention, now time.Time) []map[string]interface{} {
kept := make([]map[string]interface{}, 0, len(records))
cutoff := now.Add(-retention.MaxAge)
for _, record := range records {
if retention.MaxAge > 0 && retention.TimestampField != "" {
if raw, ok := record[retention.TimestampField].(string); ok {
if timestamp, err := time.Parse(time.RFC3339, raw); err == nil && timestamp.Before(cutoff) {
continue
}
}
}
kept = append(kept, record)
}
if retention.MaxEntries > 0 && len(kept) > retention.MaxEntries {
kept = kept[len(kept)-retention.MaxEntries:]
}
if retention.MaxBytes > 0 {
for len(kept) > 0 && ndjsonSize(kept) > retention.MaxBytes {
kept = kept[1:]
}
}
return kept
}
func ndjsonSize(records []map[string]interface{}) int64 {
var total int64
for _, record := range records {
encoded, err := json.Marshal(record)
if err == nil {
total += int64(len(encoded) + 1)
}
}
return total
}
func sameNDJSONRecords(left, right []map[string]interface{}) bool {
if len(left) != len(right) {
return false
}
for index := range left {
leftJSON, leftErr := json.Marshal(left[index])
rightJSON, rightErr := json.Marshal(right[index])
if leftErr != nil || rightErr != nil || string(leftJSON) != string(rightJSON) {
return false
}
}
return true
}
// ─── Cache JSON API ───────────────────────────────────────
// ReadPluginCacheJSON reads a named JSON cache file for a plugin.
+91
View File
@@ -0,0 +1,91 @@
package storage
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestAppendPluginDataNDJSONCompactsAndDeduplicates(t *testing.T) {
s, vaultDir := newTestStorage(t)
retention := NDJSONRetention{
TimestampField: "occurredAt",
MaxAge: 60 * 24 * time.Hour,
MaxEntries: 2,
MaxBytes: 16 * 1024,
DeduplicateField: "sourceBatchId",
DeduplicateValue: "batch-1",
}
old := time.Now().UTC().Add(-61 * 24 * time.Hour).Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
stored, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "old", "sourceBatchId": "old-batch", "occurredAt": old},
}, retention)
if err != nil || !stored {
t.Fatalf("old append = (%v, %v), want (true, nil)", stored, err)
}
stored, err = s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "current-1", "sourceBatchId": "batch-1", "occurredAt": now},
}, retention)
if err != nil || !stored {
t.Fatalf("first current append = (%v, %v), want (true, nil)", stored, err)
}
stored, err = s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "duplicate", "sourceBatchId": "batch-1", "occurredAt": now},
}, retention)
if err != nil || stored {
t.Fatalf("duplicate append = (%v, %v), want (false, nil)", stored, err)
}
for _, item := range []struct {
id string
batch string
}{
{id: "current-2", batch: "batch-3"},
{id: "current-3", batch: "batch-4"},
} {
retention.DeduplicateValue = item.batch
if stored, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": item.id, "sourceBatchId": item.batch, "occurredAt": now},
}, retention); err != nil || !stored {
t.Fatalf("append %s = (%v, %v), want (true, nil)", item.id, stored, err)
}
}
records, err := s.ReadPluginDataNDJSON("verstak.activity", "activity-events")
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 2 || records[0]["activityId"] != "current-2" || records[1]["activityId"] != "current-3" {
t.Fatalf("records = %+v, want current-2 and current-3", records)
}
if _, err := os.Stat(filepath.Join(vaultDir, "VerstakVault", ".verstak", "plugin-data", "verstak.activity", "activity-events.ndjson")); err != nil {
t.Fatalf("activity data file missing: %v", err)
}
}
func TestWritePluginDataNDJSONReplacesRecordsForExplicitUserClear(t *testing.T) {
s, _ := newTestStorage(t)
if _, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "one", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
{"activityId": "two", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
}, NDJSONRetention{}); err != nil {
t.Fatalf("AppendPluginDataNDJSON: %v", err)
}
if err := s.WritePluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "two", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
}); err != nil {
t.Fatalf("WritePluginDataNDJSON: %v", err)
}
records, err := s.ReadPluginDataNDJSON("verstak.activity", "activity-events")
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 1 || records[0]["activityId"] != "two" {
t.Fatalf("records = %+v, want only two", records)
}
}
+17
View File
@@ -70,6 +70,23 @@ func (v *Vault) GetVaultPath() string {
return v.path
}
// WithOpenPath runs fn while holding a read lease on the currently open vault.
// CloseVault and OpenVault wait until the operation completes.
func (v *Vault) WithOpenPath(fn func(string) error) error {
if v == nil {
return fmt.Errorf("vault is not initialized")
}
if fn == nil {
return fmt.Errorf("vault path operation is nil")
}
v.mu.RLock()
defer v.mu.RUnlock()
if v.status != StatusOpen || strings.TrimSpace(v.path) == "" {
return fmt.Errorf("vault is not open")
}
return fn(v.path)
}
// GetVaultMeta returns the current vault metadata.
func (v *Vault) GetVaultMeta() *VaultMeta {
v.mu.RLock()
+262 -3
View File
@@ -40,6 +40,7 @@ const (
// Workspace is a physical top-level workspace folder.
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
RootPath string `json:"rootPath"`
}
@@ -66,6 +67,7 @@ type WorkspaceTemplate struct {
// Metadata stores semantic workspace metadata that is not the source of truth
// for whether the workspace exists.
type Metadata struct {
WorkspaceID string `json:"workspaceId,omitempty"`
WorkspaceName string `json:"workspaceName"`
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
Features map[string]bool `json:"features,omitempty"`
@@ -74,6 +76,12 @@ type Metadata struct {
UpdatedAt string `json:"updatedAt,omitempty"`
}
const workspaceIdentityRelativePath = ".verstak/workspace.json"
type workspaceIdentityMarker struct {
WorkspaceID string `json:"workspaceId"`
}
// MetadataPatch updates metadata fields without replacing unspecified fields.
type MetadataPatch struct {
Features map[string]bool `json:"features,omitempty"`
@@ -82,12 +90,20 @@ type MetadataPatch struct {
// TrashResult describes a workspace moved into the internal trash area.
type TrashResult struct {
WorkspaceID string `json:"workspaceId"`
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
}
// WorkspaceIdentity identifies an existing top-level workspace independently of its path.
type WorkspaceIdentity struct {
WorkspaceID string `json:"workspaceId"`
RootPath string `json:"rootPath"`
State string `json:"state"`
}
// WorkspaceNode is a compatibility shell view of a top-level workspace.
// Path is deliberately not serialized; workspaceRootPath is derived from Name/ID.
type WorkspaceNode struct {
@@ -301,7 +317,11 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
if !entry.IsDir() {
continue
}
workspaces = append(workspaces, Workspace{Name: name, RootPath: name})
workspaceID, err := ensureWorkspaceIdentity(filepath.Join(m.vaultDir, name))
if err != nil {
return nil, err
}
workspaces = append(workspaces, Workspace{ID: workspaceID, Name: name, RootPath: name})
}
sort.Slice(workspaces, func(i, j int) bool {
return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name)
@@ -309,6 +329,84 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
return workspaces, nil
}
// GetWorkspaceIdentity resolves the durable identity for an existing workspace.
func (m *Manager) GetWorkspaceIdentity(name string) (WorkspaceIdentity, error) {
name = strings.TrimSpace(name)
if err := validateWorkspaceName(name); err != nil {
return WorkspaceIdentity{}, err
}
full := filepath.Join(m.vaultDir, name)
if err := ensureExistingWorkspaceDir(full, name); err != nil {
return WorkspaceIdentity{}, err
}
workspaceID, err := ensureWorkspaceIdentity(full)
if err != nil {
return WorkspaceIdentity{}, err
}
return WorkspaceIdentity{WorkspaceID: workspaceID, RootPath: name, State: "active"}, nil
}
// ListWorkspaceIdentities returns durable identities and flags copied markers.
func (m *Manager) ListWorkspaceIdentities() ([]WorkspaceIdentity, error) {
workspaces, err := m.ListWorkspaces()
if err != nil {
return nil, err
}
counts := make(map[string]int, len(workspaces))
for _, workspace := range workspaces {
counts[workspace.ID]++
}
identities := make([]WorkspaceIdentity, 0, len(workspaces))
for _, workspace := range workspaces {
state := "active"
if counts[workspace.ID] > 1 {
state = "duplicate"
}
identities = append(identities, WorkspaceIdentity{
WorkspaceID: workspace.ID,
RootPath: workspace.RootPath,
State: state,
})
}
return identities, nil
}
// RepairWorkspaceIdentity keeps the identity on one copied workspace and regenerates the other.
func (m *Manager) RepairWorkspaceIdentity(keepName, regenerateName string) error {
keepName = strings.TrimSpace(keepName)
regenerateName = strings.TrimSpace(regenerateName)
if err := validateWorkspaceName(keepName); err != nil {
return err
}
if err := validateWorkspaceName(regenerateName); err != nil {
return err
}
if keepName == regenerateName {
return fmt.Errorf("workspace identity repair requires two workspaces")
}
keepPath := filepath.Join(m.vaultDir, keepName)
regeneratePath := filepath.Join(m.vaultDir, regenerateName)
if err := ensureExistingWorkspaceDir(keepPath, keepName); err != nil {
return err
}
if err := ensureExistingWorkspaceDir(regeneratePath, regenerateName); err != nil {
return err
}
keepID, err := ensureWorkspaceIdentity(keepPath)
if err != nil {
return err
}
regenerateID, err := ensureWorkspaceIdentity(regeneratePath)
if err != nil {
return err
}
if keepID != regenerateID {
return fmt.Errorf("workspaces do not share an identity")
}
_, err = writeWorkspaceIdentity(regeneratePath)
return err
}
// CreateWorkspace creates a top-level workspace folder and applies a template once.
func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
name = strings.TrimSpace(name)
@@ -339,11 +437,16 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
}
}()
workspaceID, err := ensureWorkspaceIdentity(full)
if err != nil {
return Workspace{}, err
}
if err := applyTemplate(full, template); err != nil {
return Workspace{}, err
}
now := time.Now().UTC().Format(time.RFC3339Nano)
meta := Metadata{
WorkspaceID: workspaceID,
WorkspaceName: name,
CreatedFromTemplate: &TemplateSnapshot{
TemplateID: template.ID,
@@ -362,7 +465,82 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
}
created = false
return Workspace{Name: name, RootPath: name}, nil
return Workspace{ID: workspaceID, Name: name, RootPath: name}, nil
}
func writeWorkspaceIdentity(workspacePath string) (string, error) {
workspaceID := uuid.NewString()
data, err := json.Marshal(workspaceIdentityMarker{WorkspaceID: workspaceID})
if err != nil {
return "", err
}
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
return "", err
}
tmpPath := markerPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
return "", err
}
if err := os.Rename(tmpPath, markerPath); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
return workspaceID, nil
}
func ensureWorkspaceIdentity(workspacePath string) (string, error) {
workspaceID, err := readWorkspaceIdentity(workspacePath)
if os.IsNotExist(err) {
return writeWorkspaceIdentity(workspacePath)
}
if err != nil {
return "", err
}
return workspaceID, nil
}
func readWorkspaceIdentity(workspacePath string) (string, error) {
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
data, err := os.ReadFile(markerPath)
if err != nil {
return "", err
}
var marker workspaceIdentityMarker
if err := json.Unmarshal(data, &marker); err != nil {
return "", err
}
if _, err := uuid.Parse(marker.WorkspaceID); err != nil {
return "", fmt.Errorf("invalid workspace identity: %w", err)
}
return marker.WorkspaceID, nil
}
// GetWorkspaceTrashIdentity reads a trashed workspace identity without restoring it.
func (m *Manager) GetWorkspaceTrashIdentity(trashID string) (WorkspaceIdentity, error) {
trashID = strings.TrimSpace(trashID)
if err := validateWorkspaceTrashID(trashID); err != nil {
return WorkspaceIdentity{}, err
}
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
data, err := os.ReadFile(filepath.Join(trashDir, "metadata.json"))
if err != nil {
return WorkspaceIdentity{}, err
}
var trashMeta struct {
OriginalPath string `json:"originalPath"`
}
if err := json.Unmarshal(data, &trashMeta); err != nil {
return WorkspaceIdentity{}, err
}
if err := validateWorkspaceName(trashMeta.OriginalPath); err != nil {
return WorkspaceIdentity{}, err
}
workspaceID, err := readWorkspaceIdentity(filepath.Join(trashDir, trashMeta.OriginalPath))
if err != nil {
return WorkspaceIdentity{}, err
}
return WorkspaceIdentity{WorkspaceID: workspaceID, RootPath: trashMeta.OriginalPath, State: "trashed"}, nil
}
// ListWorkspaceTemplates returns selectable built-ins in their presentation order.
@@ -456,6 +634,10 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
if err := ensureExistingWorkspaceDir(full, name); err != nil {
return TrashResult{}, err
}
workspaceID, err := ensureWorkspaceIdentity(full)
if err != nil {
return TrashResult{}, err
}
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
@@ -468,7 +650,7 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
return TrashResult{}, err
}
result := TrashResult{OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt}
result := TrashResult{WorkspaceID: workspaceID, OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt}
trashMeta := map[string]string{
"originalPath": name,
"trashPath": trashRel,
@@ -500,6 +682,83 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
return result, nil
}
// RestoreWorkspaceTrash restores a trashed workspace under targetName without changing its identity.
func (m *Manager) RestoreWorkspaceTrash(trashID, targetName string) (Workspace, error) {
trashID = strings.TrimSpace(trashID)
targetName = strings.TrimSpace(targetName)
if err := validateWorkspaceTrashID(trashID); err != nil {
return Workspace{}, err
}
if err := validateWorkspaceName(targetName); err != nil {
return Workspace{}, err
}
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
data, err := os.ReadFile(filepath.Join(trashDir, "metadata.json"))
if err != nil {
return Workspace{}, err
}
var trashMeta struct {
OriginalPath string `json:"originalPath"`
}
if err := json.Unmarshal(data, &trashMeta); err != nil {
return Workspace{}, err
}
if err := validateWorkspaceName(trashMeta.OriginalPath); err != nil {
return Workspace{}, err
}
payloadPath := filepath.Join(trashDir, trashMeta.OriginalPath)
if err := ensureExistingWorkspaceDir(payloadPath, trashMeta.OriginalPath); err != nil {
return Workspace{}, err
}
targetPath := filepath.Join(m.vaultDir, targetName)
if _, err := os.Lstat(targetPath); err == nil {
return Workspace{}, fmt.Errorf("conflict: %s", targetName)
} else if !os.IsNotExist(err) {
return Workspace{}, err
}
if err := os.Rename(payloadPath, targetPath); err != nil {
return Workspace{}, err
}
restored := true
defer func() {
if restored {
_ = os.Rename(targetPath, payloadPath)
}
}()
if err := moveIfExists(filepath.Join(trashDir, "workspace.metadata.json"), m.metadataPath(targetName)); err != nil {
return Workspace{}, err
}
workspaceID, err := ensureWorkspaceIdentity(targetPath)
if err != nil {
return Workspace{}, err
}
if err := os.RemoveAll(trashDir); err != nil {
return Workspace{}, err
}
restored = false
return Workspace{ID: workspaceID, Name: targetName, RootPath: targetName}, nil
}
func validateWorkspaceTrashID(trashID string) error {
if trashID == "" || strings.ContainsAny(trashID, `/\\`) || filepath.Clean(trashID) != trashID {
return fmt.Errorf("invalid workspace trash ID")
}
return nil
}
// PurgeWorkspaceTrash permanently removes a workspace trash entry.
func (m *Manager) PurgeWorkspaceTrash(trashID string) error {
trashID = strings.TrimSpace(trashID)
if err := validateWorkspaceTrashID(trashID); err != nil {
return err
}
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
if _, err := os.Stat(filepath.Join(trashDir, "metadata.json")); err != nil {
return err
}
return os.RemoveAll(trashDir)
}
// GetWorkspaceMetadata returns stored metadata or safe generic metadata.
func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) {
name = strings.TrimSpace(name)
+161
View File
@@ -292,6 +292,167 @@ func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) {
}
}
func TestCreateWorkspaceWritesDurableIdentityMarker(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Client", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
markerPath := filepath.Join(vaultDir, "Client", ".verstak", "workspace.json")
data, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("read identity marker: %v", err)
}
var marker map[string]string
if err := json.Unmarshal(data, &marker); err != nil {
t.Fatalf("decode identity marker: %v", err)
}
if marker["workspaceId"] == "" {
t.Fatalf("workspaceId = %q, want UUID", marker["workspaceId"])
}
}
func TestListWorkspacesKeepsIdentityAfterRename(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
created, err := m.CreateWorkspace("Client", "default")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := m.RenameWorkspace("Client", "Client-2026"); err != nil {
t.Fatalf("RenameWorkspace: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 1 {
t.Fatalf("workspaces = %+v, want one", workspaces)
}
if workspaces[0].Name != "Client-2026" {
t.Fatalf("workspace name = %q, want Client-2026", workspaces[0].Name)
}
if workspaces[0].ID != created.ID {
t.Fatalf("workspace ID = %q, want %q", workspaces[0].ID, created.ID)
}
}
func TestListWorkspaceIdentitiesMarksCopiedMarkerAsDuplicate(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Original", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil {
t.Fatalf("mkdir copied: %v", err)
}
originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json")
copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil {
t.Fatalf("mkdir copied marker: %v", err)
}
data, err := os.ReadFile(originalMarker)
if err != nil {
t.Fatalf("read original marker: %v", err)
}
if err := os.WriteFile(copiedMarker, data, 0o644); err != nil {
t.Fatalf("write copied marker: %v", err)
}
identities, err := m.ListWorkspaceIdentities()
if err != nil {
t.Fatalf("ListWorkspaceIdentities: %v", err)
}
if len(identities) != 2 {
t.Fatalf("identities = %+v, want two", identities)
}
for _, identity := range identities {
if identity.State != "duplicate" {
t.Fatalf("identity %+v state = %q, want duplicate", identity, identity.State)
}
}
}
func TestRepairWorkspaceIdentityRegeneratesCopiedMarker(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Original", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil {
t.Fatalf("mkdir copied: %v", err)
}
originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json")
copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil {
t.Fatalf("mkdir copied marker: %v", err)
}
data, err := os.ReadFile(originalMarker)
if err != nil {
t.Fatalf("read original marker: %v", err)
}
if err := os.WriteFile(copiedMarker, data, 0o644); err != nil {
t.Fatalf("write copied marker: %v", err)
}
if err := m.RepairWorkspaceIdentity("Original", "Copied"); err != nil {
t.Fatalf("RepairWorkspaceIdentity: %v", err)
}
identities, err := m.ListWorkspaceIdentities()
if err != nil {
t.Fatalf("ListWorkspaceIdentities: %v", err)
}
if len(identities) != 2 || identities[0].State != "active" || identities[1].State != "active" {
t.Fatalf("identities after repair = %+v", identities)
}
if identities[0].WorkspaceID == identities[1].WorkspaceID {
t.Fatalf("repair kept duplicate ID %q", identities[0].WorkspaceID)
}
}
func TestRestoreWorkspaceTrashPreservesIdentity(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
created, err := m.CreateWorkspace("Client", "default")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
trashed, err := m.TrashWorkspace("Client")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
restored, err := m.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored")
if err != nil {
t.Fatalf("RestoreWorkspaceTrash: %v", err)
}
if restored.Name != "Client-Restored" || restored.ID != created.ID {
t.Fatalf("restored = %+v, want name Client-Restored and ID %q", restored, created.ID)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Client-Restored", ".verstak", "workspace.json")); err != nil {
t.Fatalf("restored marker: %v", err)
}
}
func TestPurgeWorkspaceTrashRemovesPayload(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Client", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
trashed, err := m.TrashWorkspace("Client")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
if err := m.PurgeWorkspaceTrash(trashed.TrashID); err != nil {
t.Fatalf("PurgeWorkspaceTrash: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, ".verstak", "trash", "workspaces", trashed.TrashID)); !os.IsNotExist(err) {
t.Fatalf("workspace trash remains, stat err=%v", err)
}
}
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
+74
View File
@@ -0,0 +1,74 @@
// Package tray owns the native tray menu wiring for the desktop shell.
package tray
import "sync"
// MenuItem exposes click events from a native tray menu item.
type MenuItem interface {
Clicked() <-chan struct{}
}
// Backend is the platform tray implementation.
type Backend interface {
Register(onReady func(), onExit func())
SetIcon(icon []byte)
SetTooltip(tooltip string)
AddMenuItem(title, tooltip string) MenuItem
Quit()
}
// Actions are executed from the native tray menu.
type Actions struct {
Show func()
Quit func()
}
// Controller initializes one native tray and routes its menu actions.
type Controller struct {
backend Backend
icon []byte
start sync.Once
}
// New creates a tray controller for one application process.
func New(backend Backend, icon []byte) *Controller {
return &Controller{
backend: backend,
icon: append([]byte(nil), icon...),
}
}
// Start registers the native tray without taking over the Wails event loop.
func (c *Controller) Start(actions Actions) {
if c == nil || c.backend == nil {
return
}
c.start.Do(func() {
c.backend.Register(func() {
c.backend.SetIcon(c.icon)
c.backend.SetTooltip("Verstak")
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
quit := c.backend.AddMenuItem("Quit", "Quit Verstak")
if actions.Show != nil && show != nil {
go routeClicks(show.Clicked(), actions.Show)
}
if actions.Quit != nil && quit != nil {
go routeClicks(quit.Clicked(), actions.Quit)
}
}, nil)
})
}
// Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() {
if c == nil || c.backend == nil {
return
}
c.backend.Quit()
}
func routeClicks(clicked <-chan struct{}, action func()) {
for range clicked {
action()
}
}
+91
View File
@@ -0,0 +1,91 @@
package tray
import (
"testing"
"time"
)
type fakeMenuItem struct {
clicked chan struct{}
}
func (i *fakeMenuItem) Clicked() <-chan struct{} {
return i.clicked
}
type fakeBackend struct {
icon []byte
tooltip string
items map[string]*fakeMenuItem
quitCalls int
registering bool
}
func (b *fakeBackend) Register(onReady func(), _ func()) {
b.registering = true
onReady()
}
func (b *fakeBackend) SetIcon(icon []byte) {
b.icon = append([]byte(nil), icon...)
}
func (b *fakeBackend) SetTooltip(tooltip string) {
b.tooltip = tooltip
}
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
item := &fakeMenuItem{clicked: make(chan struct{}, 1)}
b.items[title] = item
return item
}
func (b *fakeBackend) Quit() {
b.quitCalls++
}
func waitFor(t *testing.T, signal <-chan struct{}) {
t.Helper()
select {
case <-signal:
case <-time.After(time.Second):
t.Fatal("timed out waiting for tray action")
}
}
func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 1)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1, 2, 3})
controller.Start(Actions{
Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} },
})
if !backend.registering || string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = registering:%t icon:%v tooltip:%q", backend.registering, backend.icon, backend.tooltip)
}
showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
}
showItem.clicked <- struct{}{}
waitFor(t, showCalls)
quitItem.clicked <- struct{}{}
waitFor(t, quitCalls)
}
func TestControllerStopsNativeTrayBackend(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
controller.Stop()
if backend.quitCalls != 1 {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
}
}
+14
View File
@@ -0,0 +1,14 @@
package tray
import "encoding/base64"
const iconPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACT1BMVEUAAACOjo7///8RERHY2NghISEDAwOjo6MODg7GxsYQEBDIyMgPDw/Hx8cKCgq9vb12dnaHh4fFxcV1dXWjo6OoqKinp6eNjY25ubnr6+v29vb29vb29vbx8fHV1dXm5ub9/f3z8/OUlJQAAADu7u74+Pi5ubkAAADu7u74+Pi6urq6urrs7Oz39/ezs7PZ2dn5+fn8/Pzq6uobGxtdXV3U1NTl5eXl5eXl5eXm5ubd3d2urq7////+/v7//v7//v/s7OzLy8vLy8ru7u708/POzc3Kysrd3d39/f3Z2dnU1NT7+/u0tLQpKSkoKCcnJycsLCy+vr63t7crKyskJCRzc3NQUFAlJSUoKChvb28gICAdHR0cHBzDwsJ6enoaGhkZGRltbW3MzMwqKioeHh6pqam+vb0iIiJGRkYbGxseHR0aGhpubm6cnJw6Ojrg4ODExMQmJiYeHh36+vppaWhxcXH8/PwvLy/a2tmKiorl5eU+Pj6zs7PQ0NA1NTXR0dFSUlIbGhq/v79BQUHn5+fW1tYuLi48PDyysrItLS02NjaNjY17e3v///7b29syMjJ/f38hISEqKSlqampaWlq9vb3f398cGxs9PT1KSko3NzeioqJRUVFJSUnk5OQaGRljY2OGhobp6ek/Pz+dnZ3IyMgoKSjHx8ZDQ0PHx8fx8fHw8PBVVVX39/cdHh2RkZHz8/NPT0+Ojo7Dw8PPz8/29vZUVFTCwsL19fX4+PhYWFgWFhYXFxfAwMCqqqqFhYWHhoaEhITe3t6MjIzi4uIwLXXJAAAAPHRSTlMAAAAAAAAAAAAAAAAAAAAAAAAABRIWFQoUis3R0K82b/22DQGZ2x0Cm9wfHo/UGULi+IIEAzZudHNzUA0lLC8OAAAAAWJLR0QCZgt8ZAAAAAd0SU1FB+oHDRIyN3EuEOkAAAHzSURBVDjLvdPXXxNBEAdwPHvvFewde8WyG3NGT41KlFxMwoyKguLeCCZRUCzRgIXYUexEjR17713/MPfCB+Vy8ZXfw+3tzvdlbueyslolbfr26z8gQwYqbVP1doMGD8nOsSU7Z+gwpb2sd1CGj2CZM3JURwk6jR4j37l5wDlvejrkssjBxo7rLEGX8RPk1qmqi1UXW7JU05bx5Ss0VXOvZBNzu/4Fq1bne9asZQVej+7jrnX+gB6UYNI/UFAIqK9nGzZC0Sa2uRihZAuzgK2lAB7VtU0YtJ2VIWD5DisI+QG87lAYIhTcuYsiVFFpBVo+4O49VQKAKvbuE1C6XzbWEhwoBowePBSDEvJV+0HUHE4DR44iHquN43G9yHdCROgkP2UFp8+gqDkLdcFzdL6ewH9Bfi8LuBjFS5ev0NVrAdEQRZFwpoPr5QiB8I2b1WHw1oFIMkcauHVbGAB4565sF+De/dS9tATOB8IwMO5uNEGs4aENPEpIQI+fhHR4CvjMrFvB8xcxA8RL9kqHCMZf28Gbt2RAoJG9k4Dq39vBh4+f6HPCxb4UElFZqm4FlV+/ff9Ry9nPqmTy128L6GYCR/OwNY9eE8jtLkEPZTL/z9BOmdrTHHtl2nSeMTNmzuplgt6z58zNm2dL3vwFC/u0yo/7B9C02RGfGBOeAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA3LTEzVDE0OjIyOjMzKzAwOjAwJHU36gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNy0xM1QxNDoyMjozMyswMDowMFUoj1YAAAAASUVORK5CYII="
// DefaultIcon returns the compact source-controlled Verstak tray icon.
func DefaultIcon() []byte {
icon, err := base64.StdEncoding.DecodeString(iconPNGBase64)
if err != nil {
return nil
}
return icon
}
+41
View File
@@ -0,0 +1,41 @@
package tray
import "github.com/getlantern/systray"
type systrayBackend struct{}
type systrayMenuItem struct {
item *systray.MenuItem
}
// NewNativeBackend creates the cross-platform native tray backend.
func NewNativeBackend() Backend {
return systrayBackend{}
}
func (systrayBackend) Register(onReady func(), onExit func()) {
systray.Register(onReady, onExit)
}
func (systrayBackend) SetIcon(icon []byte) {
systray.SetIcon(icon)
}
func (systrayBackend) SetTooltip(tooltip string) {
systray.SetTooltip(tooltip)
}
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem {
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
}
func (systrayBackend) Quit() {
systray.Quit()
}
func (item systrayMenuItem) Clicked() <-chan struct{} {
if item.item == nil {
return nil
}
return item.item.ClickedCh
}
+25 -2
View File
@@ -1,9 +1,11 @@
package main
import (
"context"
"embed"
"log"
"os"
"time"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -16,6 +18,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/notifications"
"github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
@@ -24,6 +27,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
"github.com/verstak/verstak-desktop/internal/shell/debug"
"github.com/verstak/verstak-desktop/internal/shell/tray"
)
//go:embed frontend/dist
@@ -91,6 +95,7 @@ func main() {
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notifications/v1",
}
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
log.Fatalf("[main] failed to register core capabilities: %v", err)
@@ -232,6 +237,8 @@ func main() {
})
}
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled)
app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
if browserReceiver != nil {
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
if err != nil {
@@ -243,7 +250,7 @@ func main() {
}
// ─── Wails App ───────────────────────────────────────────
err = wails.Run(&options.App{
appOptions := &options.App{
Title: "Verstak",
Width: 1200,
Height: 800,
@@ -251,13 +258,29 @@ func main() {
MinHeight: 600,
WindowStartState: options.Normal,
OnStartup: app.Startup,
OnDomReady: func(ctx context.Context) {
app.DomReady(ctx)
trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit})
},
OnShutdown: func(ctx context.Context) {
trayController.Stop()
app.Shutdown(ctx)
},
OnBeforeClose: app.BeforeClose,
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "605fba28-7cbf-4f14-9d1b-a4da0c1723f8",
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
app.ShowWindow()
},
},
AssetServer: &assetserver.Options{
Assets: assets,
},
Bind: []interface{}{
app,
},
})
}
err = wails.Run(appOptions)
if err != nil {
log.Fatalf("Error: %v", err)
+11
View File
@@ -0,0 +1,11 @@
Package: verstak
Version: @VERSION@
Section: utils
Priority: optional
Architecture: amd64
Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0, libayatana-appindicator3-1
Maintainer: Verstak contributors <dev@verstak.app>
Homepage: https://github.com/mirivlad/verstak
Description: Local-first workspace
Verstak is a local-first workspace for files, notes, browser captures,
activity and work-journal entries.
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
exec /opt/verstak/verstak-desktop "$@"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -eu
HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
export APPDIR="$HERE"
export LD_LIBRARY_PATH="$HERE/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export GIO_EXTRA_MODULES="$HERE/usr/lib/gio/modules"
export GSETTINGS_SCHEMA_DIR="$HERE/usr/share/glib-2.0/schemas"
export GST_PLUGIN_SYSTEM_PATH_1_0="$HERE/usr/lib/gstreamer-1.0"
export WEBKIT_EXEC_PATH="$HERE/usr/lib/webkit2gtk-4.1"
cd "$HERE/usr/bin"
exec "$HERE/usr/bin/verstak-desktop" "$@"
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Verstak
Comment=Local-first workspace
Exec=verstak-desktop %U
Icon=verstak
Terminal=false
Categories=Office;Utility;
StartupNotify=true
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="26" fill="#1c2f4a"/>
<path d="M28 34h72v18H28zm0 31h72v29H28z" fill="#72d2b3"/>
<path d="M41 52v13m23-13v13m23-13v13" stroke="#1c2f4a" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
set "ROOT=%~dp0"
start "" "%ROOT%verstak-desktop.exe" %*
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OFFICIAL_PLUGINS="${VERSTAK_OFFICIAL_PLUGINS_DIR:-$ROOT/../verstak-official-plugins}"
OUTPUT="${VERSTAK_LINUX_BUNDLE_DIR:-$ROOT/build/linux-amd64}"
if [[ ! -d "$OFFICIAL_PLUGINS" ]]; then
echo "official plugins repository not found: $OFFICIAL_PLUGINS" >&2
exit 1
fi
echo "=== verstak desktop Linux amd64 bundle ==="
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
"$ROOT/scripts/install-dev-plugins.sh"
"$ROOT/scripts/build.sh"
BINARY="$ROOT/build/bin/verstak-desktop"
if [[ ! -x "$BINARY" ]]; then
echo "desktop binary was not produced: $BINARY" >&2
exit 1
fi
rm -rf "$OUTPUT"
mkdir -p "$OUTPUT"
install -m 755 "$BINARY" "$OUTPUT/verstak-desktop"
install -m 644 "$ROOT/README.md" "$ROOT/LICENSE" "$OUTPUT/"
cp -R "$ROOT/plugins" "$OUTPUT/plugins"
chmod -R a+rX "$OUTPUT/plugins"
echo "Linux bundle: $OUTPUT"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OFFICIAL_PLUGINS="${VERSTAK_OFFICIAL_PLUGINS_DIR:-$ROOT/../verstak-official-plugins}"
WINDOWS_PLUGIN_DIST="${VERSTAK_WINDOWS_PLUGIN_DIST:-$OFFICIAL_PLUGINS/dist-windows}"
WINDOWS_OUTPUT="${VERSTAK_WINDOWS_OUTPUT_DIR:-$ROOT/build/windows-amd64}"
WINDOWS_CC="${VERSTAK_WINDOWS_CC:-x86_64-w64-mingw32-gcc}"
if ! command -v "$WINDOWS_CC" >/dev/null; then
echo "Windows cross-compiler not found: $WINDOWS_CC" >&2
echo "Install x86_64-w64-mingw32-gcc (for example: sudo apt install gcc-mingw-w64-x86-64)." >&2
exit 1
fi
if [[ ! -d "$OFFICIAL_PLUGINS" ]]; then
echo "official plugins repository not found: $OFFICIAL_PLUGINS" >&2
exit 1
fi
WAILS="${WAILS_BIN:-}"
if [[ -z "$WAILS" ]]; then
if command -v wails >/dev/null; then
WAILS="wails"
elif [[ -n "$(go env GOBIN 2>/dev/null)" && -x "$(go env GOBIN)/wails" ]]; then
WAILS="$(go env GOBIN)/wails"
elif [[ -x "$(go env GOPATH)/bin/wails" ]]; then
WAILS="$(go env GOPATH)/bin/wails"
fi
fi
if [[ -z "$WAILS" ]]; then
echo "Wails CLI is required. Install it with: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0" >&2
exit 1
fi
cd "$ROOT"
echo "=== verstak desktop Windows amd64 build ==="
if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
if [[ -f "$ROOT/frontend/package-lock.json" ]]; then
(cd "$ROOT/frontend" && npm ci --no-audit --no-fund)
else
(cd "$ROOT/frontend" && npm install --no-audit --no-fund)
fi
fi
(cd "$ROOT/frontend" && npm run build)
go mod download
go test -count=1 ./...
"$OFFICIAL_PLUGINS/scripts/build-windows.sh"
if [[ ! -d "$WINDOWS_PLUGIN_DIST" ]]; then
echo "Windows plugin packages were not produced: $WINDOWS_PLUGIN_DIST" >&2
exit 1
fi
# Wails' -compiler option selects a Go binary, not a C compiler. Cross-CGO
# therefore has to be supplied through the standard Go environment instead.
# The portable archive uses the installed Evergreen WebView2 Runtime, so never
# compile an Evergreen downloader into the executable.
CC="$WINDOWS_CC" CGO_ENABLED=1 "$WAILS" build -clean -platform windows/amd64 \
-webview2 error -o verstak-desktop.exe
WINDOWS_BINARY="$ROOT/build/bin/verstak-desktop.exe"
if [[ ! -f "$WINDOWS_BINARY" ]]; then
echo "Windows executable was not produced: $WINDOWS_BINARY" >&2
exit 1
fi
rm -rf "$WINDOWS_OUTPUT"
mkdir -p "$WINDOWS_OUTPUT/plugins"
cp "$WINDOWS_BINARY" "$WINDOWS_OUTPUT/verstak-desktop.exe"
cp -R "$WINDOWS_PLUGIN_DIST/." "$WINDOWS_OUTPUT/plugins/"
echo "Windows test bundle: $WINDOWS_OUTPUT"
echo "Copy this directory to Windows and run verstak-desktop.exe."
+9
View File
@@ -26,6 +26,15 @@ if ! command -v npm &>/dev/null; then
fi
echo " ✅ npm $(npm --version)"
if [[ "$(go env GOOS)" == "linux" ]]; then
if ! command -v pkg-config &>/dev/null || ! pkg-config --exists ayatana-appindicator3-0.1; then
echo " ❌ native tray development files are missing"
echo " Debian/Ubuntu: sudo apt install libayatana-appindicator3-dev"
exit 1
fi
echo " ✅ ayatana-appindicator3 $(pkg-config --modversion ayatana-appindicator3-0.1)"
fi
# ── Frontend (build first — Go //go:embed needs frontend/dist/) ──
echo ""
echo "[frontend]"
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
APPIMAGETOOL_URL="${APPIMAGETOOL_URL:-https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v ldd >/dev/null || ! command -v file >/dev/null; then
echo "ldd and file are required to bundle AppImage runtime libraries" >&2
exit 1
fi
"$ROOT/scripts/build-linux-bundle.sh"
APPIMAGETOOL="${APPIMAGETOOL_BIN:-$ROOT/build/tools/appimagetool-x86_64.AppImage}"
if [[ ! -x "$APPIMAGETOOL" ]]; then
if ! command -v curl >/dev/null; then
echo "appimagetool is missing; install curl or set APPIMAGETOOL_BIN" >&2
exit 1
fi
mkdir -p "$(dirname "$APPIMAGETOOL")"
curl --fail --location --show-error "$APPIMAGETOOL_URL" -o "$APPIMAGETOOL"
chmod +x "$APPIMAGETOOL"
fi
BUNDLE="${VERSTAK_LINUX_BUNDLE_DIR:-$ROOT/build/linux-amd64}"
APPDIR="$ROOT/build/appimage/AppDir"
RELEASE_ROOT="$ROOT/release"
ARCHIVE="$RELEASE_ROOT/verstak-linux-x86_64-$VERSION.AppImage"
WEBKIT_RUNTIME_DIR="${VERSTAK_WEBKIT_RUNTIME_DIR:-/usr/lib/x86_64-linux-gnu/webkit2gtk-4.1}"
if [[ ! -d "$WEBKIT_RUNTIME_DIR" ]] || [[ ! -x "$WEBKIT_RUNTIME_DIR/WebKitWebProcess" ]]; then
echo "WebKitWebProcess was not found at $WEBKIT_RUNTIME_DIR" >&2
echo "Set VERSTAK_WEBKIT_RUNTIME_DIR to the WebKitGTK 4.1 runtime directory." >&2
exit 1
fi
rm -rf "$APPDIR" "$ARCHIVE"
mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/scalable/apps"
install -m 755 "$ROOT/packaging/linux/AppRun" "$APPDIR/AppRun"
install -m 755 "$BUNDLE/verstak-desktop" "$APPDIR/usr/bin/verstak-desktop"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/usr/share/applications/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$APPDIR/verstak.svg"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$APPDIR/usr/share/icons/hicolor/scalable/apps/verstak.svg"
cp -R "$BUNDLE/plugins" "$APPDIR/usr/bin/plugins"
cp -a "$WEBKIT_RUNTIME_DIR" "$APPDIR/usr/lib/webkit2gtk-4.1"
copy_runtime_dir() {
local source="$1"
local target="$2"
if [[ -d "$source" ]]; then
mkdir -p "$target"
cp -a "$source/." "$target/"
fi
}
copy_runtime_dir /usr/lib/x86_64-linux-gnu/gio/modules "$APPDIR/usr/lib/gio/modules"
copy_runtime_dir /usr/lib/x86_64-linux-gnu/gstreamer-1.0 "$APPDIR/usr/lib/gstreamer-1.0"
copy_runtime_dir /usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0 "$APPDIR/usr/lib/gdk-pixbuf-2.0"
copy_runtime_dir /usr/share/glib-2.0/schemas "$APPDIR/usr/share/glib-2.0/schemas"
declare -A queued=()
queue_elf() {
local candidate="$1"
[[ -f "$candidate" ]] || return
if [[ "$(file --brief "$candidate")" != *ELF* ]]; then
return
fi
if [[ -z "${queued[$candidate]:-}" ]]; then
queued[$candidate]=1
printf '%s\n' "$candidate" >> "$APPDIR/.elf-queue"
fi
}
queue_elf "$APPDIR/usr/bin/verstak-desktop"
while IFS= read -r candidate; do
queue_elf "$candidate"
done < <(find "$APPDIR/usr/lib/webkit2gtk-4.1" "$APPDIR/usr/lib/gio/modules" \
"$APPDIR/usr/lib/gstreamer-1.0" "$APPDIR/usr/lib/gdk-pixbuf-2.0" -type f)
while IFS= read -r candidate; do
if ldd "$candidate" | grep -q 'not found'; then
echo "unresolved shared library in $candidate" >&2
exit 1
fi
while IFS= read -r library; do
case "$(basename "$library")" in
libc.so.6|libm.so.6|libpthread.so.0|librt.so.1|libdl.so.2|ld-linux-x86-64.so.2)
continue
;;
esac
bundled="$APPDIR/usr/lib/$(basename "$library")"
if [[ ! -e "$bundled" ]]; then
cp -aL "$library" "$bundled"
fi
queue_elf "$bundled"
done < <(ldd "$candidate" | awk '/=> \/[^ ]+/ { print $3 } /^\// { print $1 }')
done < "$APPDIR/.elf-queue"
rm -f "$APPDIR/.elf-queue"
if ! compgen -G "$APPDIR/usr/lib/libayatana-appindicator3.so.*" >/dev/null; then
echo "native tray runtime library was not bundled" >&2
exit 1
fi
if command -v glib-compile-schemas >/dev/null; then
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas"
fi
mkdir -p "$RELEASE_ROOT"
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGETOOL" "$APPDIR" "$ARCHIVE"
(cd "$RELEASE_ROOT" && find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.zip' \) \
-printf '%f\n' | LC_ALL=C sort | xargs -r sha256sum > SHA256SUMS)
echo "AppImage: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v dpkg-deb >/dev/null; then
echo "dpkg-deb is required to create a Debian package" >&2
exit 1
fi
"$ROOT/scripts/build-linux-bundle.sh"
PACKAGE_VERSION="${VERSION#v}"
BUNDLE="${VERSTAK_LINUX_BUNDLE_DIR:-$ROOT/build/linux-amd64}"
STAGING="$ROOT/build/deb/verstak_$PACKAGE_VERSION"
RELEASE_ROOT="$ROOT/release"
ARCHIVE="$RELEASE_ROOT/verstak_${PACKAGE_VERSION}_amd64.deb"
rm -rf "$STAGING" "$ARCHIVE"
mkdir -p "$STAGING/DEBIAN" "$STAGING/opt/verstak" "$STAGING/usr/bin" \
"$STAGING/usr/share/applications" "$STAGING/usr/share/icons/hicolor/scalable/apps" \
"$STAGING/usr/share/doc/verstak"
sed "s/@VERSION@/$PACKAGE_VERSION/g" "$ROOT/packaging/deb/control" > "$STAGING/DEBIAN/control"
install -m 755 "$ROOT/packaging/deb/verstak" "$STAGING/usr/bin/verstak"
install -m 755 "$BUNDLE/verstak-desktop" "$STAGING/opt/verstak/verstak-desktop"
install -m 644 "$BUNDLE/README.md" "$BUNDLE/LICENSE" "$STAGING/usr/share/doc/verstak/"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$STAGING/usr/share/applications/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$STAGING/usr/share/icons/hicolor/scalable/apps/verstak.svg"
cp -R "$BUNDLE/plugins" "$STAGING/opt/verstak/plugins"
mkdir -p "$RELEASE_ROOT"
dpkg-deb --root-owner-group --build "$STAGING" "$ARCHIVE"
(cd "$RELEASE_ROOT" && find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.zip' \) \
-printf '%f\n' | LC_ALL=C sort | xargs -r sha256sum > SHA256SUMS)
echo "Debian package: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
for command in zip; do
if ! command -v "$command" >/dev/null; then
echo "$command is required to create the Windows portable archive" >&2
exit 1
fi
done
"$ROOT/scripts/build-windows.sh"
RELEASE_ROOT="$ROOT/release"
STAGING="$RELEASE_ROOT/verstak-windows-amd64-$VERSION"
ARCHIVE="$RELEASE_ROOT/verstak-windows-amd64-$VERSION.zip"
rm -rf "$STAGING" "$ARCHIVE"
mkdir -p "$STAGING"
cp -R "$ROOT/build/windows-amd64/." "$STAGING/"
install -m 644 "$ROOT/README.md" "$ROOT/LICENSE" "$STAGING/"
install -m 644 "$ROOT/packaging/windows/Verstak.cmd" "$STAGING/Verstak.cmd"
(cd "$RELEASE_ROOT" && zip -qr "$(basename "$ARCHIVE")" "$(basename "$STAGING")")
(cd "$RELEASE_ROOT" && find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.zip' \) \
-printf '%f\n' | LC_ALL=C sort | xargs -r sha256sum > SHA256SUMS)
echo "Windows portable archive: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
REPOSITORY="mirivlad/verstak"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}"
cd "$ROOT"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v "$GH_BIN" >/dev/null; then
echo "gh CLI is required to publish a GitHub Release" >&2
exit 1
fi
if [[ "$("$GIT_BIN" branch --show-current)" != "main" ]]; then
echo "GitHub releases must be published from main" >&2
exit 1
fi
if [[ -n "$("$GIT_BIN" status --porcelain)" ]]; then
echo "working tree must be clean before publishing a release" >&2
exit 1
fi
"$GH_BIN" auth status
"$GIT_BIN" fetch origin main --tags
HEAD="$("$GIT_BIN" rev-parse HEAD)"
if [[ "$HEAD" != "$("$GIT_BIN" rev-parse origin/main)" ]]; then
echo "local main must match origin/main before publishing a release" >&2
exit 1
fi
"$RELEASE_SCRIPT" "$VERSION"
ASSETS=(
"$RELEASE_DIR/verstak_${VERSION#v}_amd64.deb"
"$RELEASE_DIR/verstak-linux-x86_64-$VERSION.AppImage"
"$RELEASE_DIR/verstak-windows-amd64-$VERSION.zip"
"$RELEASE_DIR/SHA256SUMS"
)
for asset in "${ASSETS[@]}"; do
if [[ ! -s "$asset" ]]; then
echo "required release asset is missing or empty: $asset" >&2
exit 1
fi
done
if "$GIT_BIN" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
if [[ "$("$GIT_BIN" rev-parse "${VERSION}^{commit}")" != "$HEAD" ]]; then
echo "existing tag $VERSION does not point at HEAD" >&2
exit 1
fi
else
"$GIT_BIN" tag -a "$VERSION" -m "Release $VERSION"
"$GIT_BIN" push origin "refs/tags/$VERSION"
fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else
RELEASE_OPTIONS=(--generate-notes --verify-tag)
if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease)
else
RELEASE_OPTIONS+=(--latest)
fi
"$GH_BIN" release create "$VERSION" "${ASSETS[@]}" \
--repo "$REPOSITORY" \
--title "Verstak $VERSION" \
"${RELEASE_OPTIONS[@]}"
fi
echo "GitHub release:"
"$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" --json url --jq .url
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
echo "=== verstak desktop release $VERSION ==="
RELEASE_ROOT="$ROOT/release"
rm -rf "$RELEASE_ROOT"
mkdir -p "$RELEASE_ROOT"
"$ROOT/scripts/package-deb.sh" "$VERSION"
"$ROOT/scripts/package-appimage.sh" "$VERSION"
"$ROOT/scripts/package-windows-portable.sh" "$VERSION"
(cd "$RELEASE_ROOT" && find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.zip' \) \
-printf '%f\n' | LC_ALL=C sort | xargs -r sha256sum > SHA256SUMS)
echo "release assets: $RELEASE_ROOT"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILDER="$ROOT/scripts/build-windows.sh"
if [[ ! -x "$BUILDER" ]]; then
echo "Windows desktop builder is missing or not executable: $BUILDER" >&2
exit 1
fi
bash -n "$BUILDER"
grep -Fq -- '-platform windows/amd64' "$BUILDER"
grep -Fq 'CC="$WINDOWS_CC"' "$BUILDER"
grep -Fq 'CGO_ENABLED=1' "$BUILDER"
grep -Fq -- '-webview2 error' "$BUILDER"
grep -Fq 'dist-windows' "$BUILDER"
grep -Fq 'verstak-desktop.exe' "$BUILDER"
grep -Fq 'x86_64-w64-mingw32-gcc' "$BUILDER"
echo "Windows desktop build script contract passed"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
for script in build-linux-bundle.sh package-deb.sh package-appimage.sh package-windows-portable.sh; do
path="$ROOT/scripts/$script"
if [[ ! -x "$path" ]]; then
echo "packaging script is missing or not executable: $path" >&2
exit 1
fi
bash -n "$path"
done
grep -Fq 'dpkg-deb' "$ROOT/scripts/package-deb.sh"
grep -Fq -- '--build' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/control' "$ROOT/scripts/package-deb.sh"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control"
grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control"
grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'libayatana-appindicator3.so.' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'ayatana-appindicator3-0.1' "$ROOT/scripts/build.sh"
if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle Fixed Version WebView2" >&2
exit 1
fi
if grep -Fq 'msedgewebview2.exe' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle WebView2 binaries" >&2
exit 1
fi
if [[ -e "$ROOT/platform_options_windows.go" ]]; then
echo "Windows runtime override must not be present" >&2
exit 1
fi
grep -Fq 'zip -qr' "$ROOT/scripts/package-windows-portable.sh"
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 '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"
echo "desktop package script contracts passed"
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLISHER="$ROOT/scripts/publish-github-release.sh"
VERSION="v0.0.0-test"
REPOSITORY="mirivlad/verstak"
ASSET_NAMES=(
"verstak_0.0.0-test_amd64.deb"
"verstak-linux-x86_64-v0.0.0-test.AppImage"
"verstak-windows-amd64-v0.0.0-test.zip"
)
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [[ ! -x "$PUBLISHER" ]]; then
echo "publisher is missing or not executable: $PUBLISHER" >&2
exit 1
fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
LOG="$WORK/log"
export LOG
cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf 'release:%s\n' "$1" >> "$LOG"
printf 'deb\n' > "$VERSTAK_RELEASE_DIR/verstak_${1#v}_amd64.deb"
printf 'appimage\n' > "$VERSTAK_RELEASE_DIR/verstak-linux-x86_64-$1.AppImage"
printf 'zip\n' > "$VERSTAK_RELEASE_DIR/verstak-windows-amd64-$1.zip"
printf 'checksum\n' > "$VERSTAK_RELEASE_DIR/SHA256SUMS"
SCRIPT
chmod +x "$WORK/release.sh"
cat > "$WORK/bin/git" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$PWD" != "${EXPECTED_ROOT:?}" ]]; then
echo "publisher did not enter repository root: $PWD" >&2
exit 1
fi
case "${1:-}" in
status) exit 0 ;;
branch) echo main ;;
fetch) printf 'fetch\n' >> "$LOG" ;;
rev-parse)
if [[ "${2:-}" == "-q" ]]; then
if [[ -f "$TEST_STATE/tag" ]]; then echo test-commit; exit 0; fi
exit 1
fi
echo test-commit
;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/git"
cat > "$WORK/bin/gh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}:${2:-}" in
auth:status) printf 'auth\n' >> "$LOG" ;;
release:view)
if [[ -f "$TEST_STATE/release" ]]; then echo https://github.example/release; else exit 1; fi
;;
release:create) touch "$TEST_STATE/release"; printf 'create:%s\n' "$*" >> "$LOG" ;;
release:upload) printf 'upload:%s\n' "$*" >> "$LOG" ;;
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/gh"
run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \
GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \
TEST_STATE="$WORK/state" \
"$PUBLISHER" "$VERSION"
}
run_publisher
grep -Fqx "release:$VERSION" "$LOG"
grep -Fqx "tag:$VERSION" "$LOG"
grep -Fqx "push:origin:refs/tags/$VERSION" "$LOG"
grep -F "release create $VERSION" "$LOG" >/dev/null
for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null
done
grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher
grep -F "release upload $VERSION" "$LOG" >/dev/null
echo "GitHub release publisher test passed"
+8
View File
@@ -39,6 +39,14 @@ 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"
WAILS_BINDINGS_STATUS=0
(cd "$ROOT" && node frontend/tests/wails-bindings-test.mjs) || WAILS_BINDINGS_STATUS=$?
report "Wails notification bindings" "$WAILS_BINDINGS_STATUS"
DEBUG_MODE_STATUS=0
(cd "$ROOT" && node --experimental-vm-modules frontend/tests/debug-mode-test.mjs) || DEBUG_MODE_STATUS=$?
report "session-only debug mode" "$DEBUG_MODE_STATUS"
# ── Frontend tests ──
echo "[frontend]"
if ensure_npm_deps "$ROOT/frontend"; then