Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1f9157c05 | ||
|
|
a00207d86e | ||
|
|
fe912c9659 | ||
|
|
8e3dedf0c5 | ||
|
|
d8a4213569 | ||
|
|
e33c63d3ad | ||
|
|
96dd499e0b | ||
|
|
67753927cb | ||
|
|
55edaa0209 | ||
|
|
4d6a8bd8cd | ||
|
|
9fc0ff764c | ||
|
|
eb5d6b1f06 | ||
|
|
030f66d806 | ||
|
|
da76b89856 | ||
|
|
20952e8477 | ||
|
|
f4e911c14a | ||
|
|
47a8d565e8 | ||
|
|
581e6b27d8 | ||
|
|
ca5290015a | ||
|
|
ad0c00bade | ||
|
|
4db5a7a132 | ||
|
|
c567b229cc | ||
|
|
5a249dcf24 | ||
|
|
6b556c59d9 | ||
|
|
fca9fff4b4 | ||
|
|
bcf49ed32c | ||
|
|
39052ffe39 | ||
|
|
e1a6b2af64 | ||
|
|
5280d245ee |
+3
-1
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
[](https://github.com/mirivlad/verstak/releases)
|
||||

|
||||

|
||||
[](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. |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| Notes: keep Markdown notes next to the workspace they describe. | Journal: record a completed session and retain its context. |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## 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
@@ -0,0 +1,340 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="packaging/linux/verstak.svg" width="112" alt="Логотип Верстака">
|
||||
|
||||
# Верстак
|
||||
|
||||
### Рабочий контекст остаётся рядом — и хранится локально.
|
||||
|
||||
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
|
||||
в одном расширяемом рабочем пространстве.
|
||||
|
||||
[English](README.md) · **Русский**
|
||||
|
||||
[](https://github.com/mirivlad/verstak/releases)
|
||||

|
||||

|
||||
[](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**, который остаётся под вашим контролем.
|
||||
|
||||
Для локальной работы не нужны учётная запись, облачный сервис или собственный сервер.
|
||||
|
||||
## Верстак в работе
|
||||
|
||||
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## Основные возможности
|
||||
|
||||
| Возможность | Что она даёт |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| **Дела** | Объединяют файлы, заметки и историю работы вокруг проектов, клиентов и других направлений. |
|
||||
| **Файлы** | Позволяют работать с обычными файлами, расположенными внутри 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.
|
||||
@@ -54,6 +54,7 @@
|
||||
const name = workspaceName(workspace);
|
||||
return {
|
||||
id: name,
|
||||
workspaceId: workspace?.id || workspace?.workspaceId || '',
|
||||
type: workspace?.type || 'space',
|
||||
title: workspace?.title || name,
|
||||
name,
|
||||
@@ -218,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: '' };
|
||||
|
||||
@@ -127,9 +127,11 @@ export var debug = {
|
||||
log: log,
|
||||
logf: logf,
|
||||
isEnabled: function () { return ENABLED; },
|
||||
enable: function () {
|
||||
enable: function (options) {
|
||||
ENABLED = true;
|
||||
if (!options || options.persist !== false) {
|
||||
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
|
||||
}
|
||||
},
|
||||
disable: function () {
|
||||
ENABLED = false;
|
||||
|
||||
@@ -220,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');
|
||||
@@ -262,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) {
|
||||
@@ -409,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() {
|
||||
|
||||
@@ -136,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);
|
||||
@@ -501,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(),
|
||||
@@ -511,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',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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();
|
||||
@@ -53,7 +54,9 @@
|
||||
{: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>
|
||||
{#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>
|
||||
|
||||
@@ -43,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) {
|
||||
@@ -235,7 +236,7 @@
|
||||
<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}
|
||||
|
||||
@@ -3341,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 || {}, '');
|
||||
},
|
||||
@@ -3678,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);
|
||||
|
||||
@@ -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');
|
||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const pluginData = {};
|
||||
const scheduledNotifications = [];
|
||||
|
||||
globalThis.window = {
|
||||
__VERSTAK_PLUGIN_REGISTRY__: {},
|
||||
@@ -38,6 +39,14 @@ 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('');
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -116,6 +125,28 @@ 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');
|
||||
|
||||
@@ -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');
|
||||
Vendored
+31
-1
@@ -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>;
|
||||
@@ -72,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>;
|
||||
@@ -120,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>;
|
||||
@@ -138,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>;
|
||||
@@ -150,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>;
|
||||
@@ -168,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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -130,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);
|
||||
}
|
||||
@@ -146,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);
|
||||
}
|
||||
@@ -226,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);
|
||||
}
|
||||
@@ -262,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']();
|
||||
}
|
||||
@@ -286,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);
|
||||
}
|
||||
@@ -322,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);
|
||||
}
|
||||
|
||||
@@ -538,6 +538,29 @@ export namespace files {
|
||||
|
||||
}
|
||||
|
||||
export namespace notifications {
|
||||
|
||||
export class Request {
|
||||
id: string;
|
||||
dueAt: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Request(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.dueAt = source["dueAt"];
|
||||
this.title = source["title"];
|
||||
this.body = source["body"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace permissions {
|
||||
|
||||
export class Entry {
|
||||
@@ -1230,27 +1253,6 @@ export namespace workbench {
|
||||
|
||||
export namespace workspace {
|
||||
|
||||
export class WorkspaceTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
workspaceTools: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WorkspaceTemplate(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.description = source["description"];
|
||||
this.version = source["version"];
|
||||
this.workspaceTools = source["workspaceTools"];
|
||||
}
|
||||
}
|
||||
|
||||
export class TemplateSnapshot {
|
||||
templateId: string;
|
||||
templateName: string;
|
||||
@@ -1272,6 +1274,7 @@ export namespace workspace {
|
||||
}
|
||||
}
|
||||
export class Metadata {
|
||||
workspaceId?: string;
|
||||
workspaceName: string;
|
||||
createdFromTemplate?: TemplateSnapshot;
|
||||
features?: Record<string, boolean>;
|
||||
@@ -1285,6 +1288,7 @@ export namespace workspace {
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.workspaceId = source["workspaceId"];
|
||||
this.workspaceName = source["workspaceName"];
|
||||
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
|
||||
this.features = source["features"];
|
||||
@@ -1327,6 +1331,7 @@ export namespace workspace {
|
||||
}
|
||||
|
||||
export class TrashResult {
|
||||
workspaceId: string;
|
||||
originalPath: string;
|
||||
trashPath: string;
|
||||
trashId: string;
|
||||
@@ -1338,6 +1343,7 @@ export namespace workspace {
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.workspaceId = source["workspaceId"];
|
||||
this.originalPath = source["originalPath"];
|
||||
this.trashPath = source["trashPath"];
|
||||
this.trashId = source["trashId"];
|
||||
@@ -1345,6 +1351,7 @@ export namespace workspace {
|
||||
}
|
||||
}
|
||||
export class Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
rootPath: string;
|
||||
|
||||
@@ -1354,9 +1361,46 @@ export namespace workspace {
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.rootPath = source["rootPath"];
|
||||
}
|
||||
}
|
||||
export class WorkspaceIdentity {
|
||||
workspaceId: string;
|
||||
rootPath: string;
|
||||
state: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WorkspaceIdentity(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.workspaceId = source["workspaceId"];
|
||||
this.rootPath = source["rootPath"];
|
||||
this.state = source["state"];
|
||||
}
|
||||
}
|
||||
export class WorkspaceTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
workspaceTools: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WorkspaceTemplate(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.description = source["description"];
|
||||
this.version = source["version"];
|
||||
this.workspaceTools = source["workspaceTools"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+485
-27
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/verstak/verstak-desktop/internal/core/externalopen"
|
||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
||||
"github.com/verstak/verstak-desktop/internal/core/filewatcher"
|
||||
"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"
|
||||
@@ -38,11 +39,28 @@ import (
|
||||
|
||||
var newSyncClient = syncsvc.NewClient
|
||||
var emitFrontendEvent = runtime.EventsEmit
|
||||
var initializeNativeNotifications = runtime.InitializeNotifications
|
||||
var cleanupNativeNotifications = runtime.CleanupNotifications
|
||||
var sendNativeNotification = runtime.SendNotification
|
||||
var hideNativeWindow = runtime.WindowHide
|
||||
var showNativeWindow = runtime.WindowShow
|
||||
var quitNativeApplication = runtime.Quit
|
||||
|
||||
type notificationService interface {
|
||||
Replace(pluginID string, requests []notifications.Request) error
|
||||
Clear(pluginID string) error
|
||||
Start(ctx context.Context)
|
||||
Stop()
|
||||
}
|
||||
|
||||
const pluginEventRuntimeName = "verstak:plugin-event"
|
||||
const activityGlobalKey = "events:global"
|
||||
const activityWorkspacePrefix = "events:workspace:"
|
||||
const maxActivityEvents = 250
|
||||
const activityPluginID = "verstak.activity"
|
||||
const activityRawDataName = "activity-events"
|
||||
const activitySessionHandlingKey = "activity-session-handling-v2"
|
||||
const activitySessionHandledEvent = "activity.session.handled"
|
||||
const maxActivityRawEvents = 10000
|
||||
const maxActivityRawBytes = 8 * 1024 * 1024
|
||||
const activityRetention = 60 * 24 * time.Hour
|
||||
const browserInboxPluginID = "verstak.browser-inbox"
|
||||
const browserInboxGlobalKey = "captures:global"
|
||||
const browserInboxLegacyKey = "captures"
|
||||
@@ -52,6 +70,8 @@ const maxBrowserInboxCaptures = 100
|
||||
const workspaceCreatedEventName = "workspace.created"
|
||||
const workspaceRenamedEventName = "workspace.renamed"
|
||||
const workspaceTrashedEventName = "workspace.trashed"
|
||||
const workspaceRestoredEventName = "workspace.restored"
|
||||
const workspacePurgedEventName = "workspace.purged"
|
||||
const workspaceSelectedEventName = "workspace.selected"
|
||||
|
||||
// App is the main application struct exposed to the Wails frontend.
|
||||
@@ -74,10 +94,20 @@ type App struct {
|
||||
browserReceiver *browserreceiver.Receiver
|
||||
secretsSession *coresecrets.VaultSession
|
||||
fileWatcher *filewatcher.Service
|
||||
notifications notificationService
|
||||
debug bool
|
||||
activityEvents map[string]bool
|
||||
browserInboxEvents map[string]bool
|
||||
browserInboxEnabled atomic.Bool
|
||||
allowQuit atomic.Bool
|
||||
}
|
||||
|
||||
// SetNotificationService attaches the core-owned plugin notification scheduler.
|
||||
func (a *App) SetNotificationService(service notificationService) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.notifications = service
|
||||
}
|
||||
|
||||
type externalOpenService interface {
|
||||
@@ -130,6 +160,7 @@ func NewApp(
|
||||
app.ensureBrowserInboxSubscriptions()
|
||||
if app.browserReceiver != nil {
|
||||
app.browserReceiver.SetPersistence(app.browserInboxAvailable, app.recordBrowserCapture)
|
||||
app.browserReceiver.SetActivityPersistence(app.activityAvailable, app.recordBrowserActivityBatch)
|
||||
}
|
||||
app.startFileWatcherForOpenVault()
|
||||
return app
|
||||
@@ -170,6 +201,71 @@ func (a *App) Startup(ctx context.Context) {
|
||||
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
|
||||
}
|
||||
|
||||
// DomReady initializes the native notification runtime before starting schedules.
|
||||
func (a *App) DomReady(ctx context.Context) {
|
||||
if a.notifications == nil {
|
||||
return
|
||||
}
|
||||
if err := initializeNativeNotifications(ctx); err != nil {
|
||||
log.Printf("[api] native notifications unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
a.notifications.Start(ctx)
|
||||
}
|
||||
|
||||
// Shutdown stops scheduled delivery before releasing native notification resources.
|
||||
func (a *App) Shutdown(ctx context.Context) {
|
||||
if a.notifications != nil {
|
||||
a.notifications.Stop()
|
||||
}
|
||||
cleanupNativeNotifications(ctx)
|
||||
}
|
||||
|
||||
// BeforeClose hides the primary window until the user explicitly quits from the tray.
|
||||
func (a *App) BeforeClose(ctx context.Context) bool {
|
||||
if a.allowQuit.Load() {
|
||||
return false
|
||||
}
|
||||
hideNativeWindow(ctx)
|
||||
return true
|
||||
}
|
||||
|
||||
// ShowWindow brings the primary window back from the tray.
|
||||
func (a *App) ShowWindow() {
|
||||
if a == nil || a.ctx == nil {
|
||||
return
|
||||
}
|
||||
showNativeWindow(a.ctx)
|
||||
}
|
||||
|
||||
// Quit allows the close event and ends the application process.
|
||||
func (a *App) Quit() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.allowQuit.Store(true)
|
||||
if a.ctx != nil {
|
||||
quitNativeApplication(a.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// NativeNotificationSender delivers scheduler items through the Wails runtime.
|
||||
type NativeNotificationSender struct{}
|
||||
|
||||
// NewNativeNotificationSender creates the adapter used by the core scheduler.
|
||||
func NewNativeNotificationSender() notifications.Sender {
|
||||
return NativeNotificationSender{}
|
||||
}
|
||||
|
||||
// Send shows a native system notification for a scheduled plugin reminder.
|
||||
func (NativeNotificationSender) Send(ctx context.Context, item notifications.Item) error {
|
||||
return sendNativeNotification(ctx, runtime.NotificationOptions{
|
||||
ID: "verstak:" + item.PluginID + ":" + item.ID,
|
||||
Title: item.Title,
|
||||
Body: item.Body,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) ensureBrowserInboxSubscriptions() {
|
||||
if a.eventBus == nil || a.storage == nil {
|
||||
a.browserInboxEnabled.Store(false)
|
||||
@@ -183,15 +279,21 @@ func (a *App) ensureBrowserInboxSubscriptions() {
|
||||
if a.browserInboxEvents == nil {
|
||||
a.browserInboxEvents = make(map[string]bool)
|
||||
}
|
||||
for _, eventName := range []string{browserInboxMutationEvent} {
|
||||
for _, eventName := range []string{browserInboxMutationEvent, workspaceRenamedEventName, workspaceTrashedEventName, workspaceRestoredEventName, workspacePurgedEventName} {
|
||||
if a.browserInboxEvents[eventName] {
|
||||
continue
|
||||
}
|
||||
a.browserInboxEvents[eventName] = true
|
||||
a.eventBus.Subscribe(eventName, func(event events.Event) {
|
||||
if event.Name == browserInboxMutationEvent {
|
||||
if err := a.mutateBrowserInboxCapture(event); err != nil {
|
||||
log.Printf("[api] browser inbox mutation failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := a.updateBrowserInboxWorkspaceLifecycle(event); err != nil {
|
||||
log.Printf("[api] browser inbox workspace lifecycle failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -203,6 +305,78 @@ func (a *App) browserInboxAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) activityAvailable() bool {
|
||||
if a == nil || a.storage == nil || a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
|
||||
return false
|
||||
}
|
||||
_, err := a.requirePluginAccess(activityPluginID, "storage.namespace")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (a *App) recordBrowserActivityBatch(event events.Event) error {
|
||||
if !a.activityAvailable() {
|
||||
return fmt.Errorf("activity storage unavailable")
|
||||
}
|
||||
payload := eventPayloadMap(event.Payload)
|
||||
batchID := firstPayloadText(payload, "batchId")
|
||||
if batchID == "" {
|
||||
return fmt.Errorf("batchId is empty")
|
||||
}
|
||||
entries, ok := payload["entries"].([]map[string]interface{})
|
||||
if !ok || len(entries) == 0 {
|
||||
return fmt.Errorf("activity batch entries are empty")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
receivedAt := event.Timestamp
|
||||
if receivedAt == "" {
|
||||
receivedAt = now
|
||||
}
|
||||
records := make([]map[string]interface{}, 0, len(entries))
|
||||
for index, entry := range entries {
|
||||
hostname := firstPayloadText(entry, "hostname")
|
||||
endedAt := firstPayloadText(entry, "endedAt")
|
||||
if hostname == "" || endedAt == "" {
|
||||
return fmt.Errorf("activity batch entry %d is invalid", index)
|
||||
}
|
||||
durationSeconds, _ := entry["durationSeconds"].(int64)
|
||||
if durationSeconds == 0 {
|
||||
if number, ok := entry["durationSeconds"].(float64); ok {
|
||||
durationSeconds = int64(number)
|
||||
}
|
||||
}
|
||||
records = append(records, map[string]interface{}{
|
||||
"activityId": fmt.Sprintf("browser-domain:%s:%d", batchID, index),
|
||||
"type": "browser.activity.domain",
|
||||
"title": hostname,
|
||||
"summary": fmt.Sprintf("%d min browser activity", durationSeconds/60),
|
||||
"occurredAt": endedAt,
|
||||
"receivedAt": receivedAt,
|
||||
"sourcePluginId": "verstak-browser-extension",
|
||||
"sourceBatchId": batchID,
|
||||
"hostname": hostname,
|
||||
"startedAt": firstPayloadText(entry, "startedAt"),
|
||||
"endedAt": endedAt,
|
||||
"durationSeconds": durationSeconds,
|
||||
"workspaceRootPath": "",
|
||||
"payload": map[string]interface{}{
|
||||
"hostname": hostname,
|
||||
"startedAt": firstPayloadText(entry, "startedAt"),
|
||||
"endedAt": endedAt,
|
||||
"durationSeconds": durationSeconds,
|
||||
},
|
||||
})
|
||||
}
|
||||
_, err := a.storage.AppendPluginDataNDJSON(activityPluginID, activityRawDataName, records, storage.NDJSONRetention{
|
||||
TimestampField: "occurredAt",
|
||||
MaxAge: activityRetention,
|
||||
MaxEntries: maxActivityRawEvents,
|
||||
MaxBytes: maxActivityRawBytes,
|
||||
DeduplicateField: "sourceBatchId",
|
||||
DeduplicateValue: batchID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) recordBrowserCapture(event events.Event) error {
|
||||
if !a.browserInboxAvailable() {
|
||||
return fmt.Errorf("browser inbox unavailable")
|
||||
@@ -218,6 +392,10 @@ func (a *App) recordBrowserCapture(event events.Event) error {
|
||||
if firstPayloadText(capture, "capturedAt") == "" {
|
||||
capture["capturedAt"] = event.Timestamp
|
||||
}
|
||||
if firstPayloadText(capture, "globalState") == "" {
|
||||
capture["globalState"] = "inbox"
|
||||
}
|
||||
a.annotateBrowserCaptureWorkspace(capture)
|
||||
capture["receivedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
|
||||
for _, stored := range captures {
|
||||
@@ -238,7 +416,7 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
|
||||
}
|
||||
action := firstPayloadText(payload, "action")
|
||||
switch action {
|
||||
case "migrate", "assign", "delete", "processed":
|
||||
case "migrate", "assign", "archive", "restore", "delete", "processed":
|
||||
default:
|
||||
return fmt.Errorf("unsupported browser inbox mutation %q", action)
|
||||
}
|
||||
@@ -268,10 +446,17 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
|
||||
switch action {
|
||||
case "delete":
|
||||
continue
|
||||
case "archive":
|
||||
capture["globalState"] = "archived"
|
||||
case "restore":
|
||||
capture["globalState"] = "inbox"
|
||||
case "assign":
|
||||
workspaceRoot := firstPayloadText(payload, "workspaceRootPath")
|
||||
capture["workspaceRootPath"] = workspaceRoot
|
||||
capture["workspaceName"] = workspaceRoot
|
||||
delete(capture, "workspaceId")
|
||||
delete(capture, "workspaceTrashId")
|
||||
a.annotateBrowserCaptureWorkspace(capture)
|
||||
case "processed":
|
||||
capture["processed"], _ = payload["processed"].(bool)
|
||||
}
|
||||
@@ -281,6 +466,67 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) annotateBrowserCaptureWorkspace(capture map[string]interface{}) {
|
||||
if capture == nil {
|
||||
return
|
||||
}
|
||||
workspaceRoot := firstPayloadText(capture, "workspaceRootPath")
|
||||
if workspaceRoot == "" {
|
||||
capture["workspaceState"] = "unassigned"
|
||||
delete(capture, "workspaceId")
|
||||
delete(capture, "workspaceTrashId")
|
||||
return
|
||||
}
|
||||
if firstPayloadText(capture, "workspaceId") != "" {
|
||||
if firstPayloadText(capture, "workspaceState") == "" {
|
||||
capture["workspaceState"] = "active"
|
||||
}
|
||||
return
|
||||
}
|
||||
if a.workspace == nil {
|
||||
capture["workspaceState"] = "unavailable"
|
||||
return
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot)
|
||||
if err != nil {
|
||||
capture["workspaceState"] = "unavailable"
|
||||
return
|
||||
}
|
||||
capture["workspaceId"] = identity.WorkspaceID
|
||||
capture["workspaceRootPath"] = identity.RootPath
|
||||
capture["workspaceName"] = identity.RootPath
|
||||
capture["workspaceState"] = identity.State
|
||||
}
|
||||
|
||||
func (a *App) updateBrowserInboxWorkspaceLifecycle(event events.Event) error {
|
||||
payload := eventPayloadMap(event.Payload)
|
||||
workspaceID := firstPayloadText(payload, "workspaceId")
|
||||
if workspaceID == "" {
|
||||
return nil
|
||||
}
|
||||
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
|
||||
for _, capture := range captures {
|
||||
if firstPayloadText(capture, "workspaceId") != workspaceID {
|
||||
continue
|
||||
}
|
||||
switch event.Name {
|
||||
case workspaceRenamedEventName, workspaceRestoredEventName:
|
||||
capture["workspaceRootPath"] = firstPayloadText(payload, "workspaceRootPath")
|
||||
capture["workspaceName"] = firstPayloadText(payload, "workspaceName", "workspaceRootPath")
|
||||
capture["workspaceState"] = "active"
|
||||
delete(capture, "workspaceTrashId")
|
||||
case workspaceTrashedEventName:
|
||||
capture["workspaceState"] = "trashed"
|
||||
capture["workspaceTrashId"] = firstPayloadText(payload, "trashId")
|
||||
case workspacePurgedEventName:
|
||||
capture["workspaceState"] = "orphaned"
|
||||
delete(capture, "workspaceTrashId")
|
||||
}
|
||||
}
|
||||
return captures
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) updateBrowserInboxCaptures(update func([]map[string]interface{}) []map[string]interface{}) error {
|
||||
if !a.browserInboxAvailable() {
|
||||
return fmt.Errorf("browser inbox unavailable")
|
||||
@@ -334,6 +580,9 @@ func browserInboxCaptures(settings map[string]interface{}) ([]map[string]interfa
|
||||
continue
|
||||
}
|
||||
seen[captureID] = true
|
||||
if firstPayloadText(capture, "globalState") == "" {
|
||||
capture["globalState"] = "inbox"
|
||||
}
|
||||
if firstPayloadText(capture, "workspaceRootPath") == "" && workspaceRoot != "" {
|
||||
capture["workspaceRootPath"] = workspaceRoot
|
||||
capture["workspaceName"] = workspaceRoot
|
||||
@@ -406,6 +655,46 @@ func (a *App) ensureActivityProviderSubscriptions() {
|
||||
})
|
||||
}
|
||||
}
|
||||
if !a.activityEvents[activitySessionHandledEvent] {
|
||||
a.activityEvents[activitySessionHandledEvent] = true
|
||||
a.eventBus.Subscribe(activitySessionHandledEvent, func(event events.Event) {
|
||||
if err := a.recordActivitySessionHandled(event); err != nil {
|
||||
log.Printf("[api] activity session handling update failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordActivitySessionHandled(event events.Event) error {
|
||||
if !a.activityAvailable() {
|
||||
return fmt.Errorf("activity storage unavailable")
|
||||
}
|
||||
payload := eventPayloadMap(event.Payload)
|
||||
if firstPayloadText(payload, "pluginId") != "verstak.journal" {
|
||||
return fmt.Errorf("activity session handling source is not authorized")
|
||||
}
|
||||
sessionID := firstPayloadText(payload, "sessionId")
|
||||
handledThrough := firstPayloadText(payload, "handledThrough")
|
||||
status := firstPayloadText(payload, "status")
|
||||
if sessionID == "" || handledThrough == "" || (status != "accepted" && status != "dismissed") {
|
||||
return fmt.Errorf("activity session handling payload is invalid")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, handledThrough); err != nil {
|
||||
return fmt.Errorf("activity session handledThrough is invalid")
|
||||
}
|
||||
return a.storage.UpdatePluginSettings(activityPluginID, func(settings map[string]interface{}) error {
|
||||
handled, _ := settings[activitySessionHandlingKey].(map[string]interface{})
|
||||
if handled == nil {
|
||||
handled = make(map[string]interface{})
|
||||
}
|
||||
handled[sessionID] = map[string]interface{}{
|
||||
"status": status,
|
||||
"handledThrough": handledThrough,
|
||||
"handledAt": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
settings[activitySessionHandlingKey] = handled
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) recordActivityProviderEvent(event events.Event) {
|
||||
@@ -419,35 +708,20 @@ func (a *App) recordActivityProviderEvent(event events.Event) {
|
||||
if _, err := a.requirePluginAccess(provider.PluginID, "storage.namespace"); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := a.appendActivityEvent(provider.PluginID, activityFromEvent(event)); err != nil {
|
||||
if err := a.appendActivityEvent(provider.PluginID, a.activityFromEvent(event)); err != nil {
|
||||
log.Printf("[api] activity provider %s failed to record %s: %v", provider.PluginID, event.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) appendActivityEvent(pluginID string, activity map[string]interface{}) error {
|
||||
settings, err := a.storage.ReadPluginSettings(pluginID)
|
||||
if err != nil {
|
||||
_, err := a.storage.AppendPluginDataNDJSON(pluginID, activityRawDataName, []map[string]interface{}{activity}, storage.NDJSONRetention{
|
||||
TimestampField: "occurredAt",
|
||||
MaxAge: activityRetention,
|
||||
MaxEntries: maxActivityRawEvents,
|
||||
MaxBytes: maxActivityRawBytes,
|
||||
})
|
||||
return err
|
||||
}
|
||||
key := activityGlobalKey
|
||||
if workspace, _ := activity["workspaceRootPath"].(string); strings.TrimSpace(workspace) != "" {
|
||||
key = activityWorkspacePrefix + url.QueryEscape(strings.TrimSpace(workspace))
|
||||
key = strings.ReplaceAll(key, "+", "%20")
|
||||
}
|
||||
eventsList := []interface{}{activity}
|
||||
if existing, ok := settings[key].([]interface{}); ok {
|
||||
eventsList = append(eventsList, existing...)
|
||||
} else if existingMaps, ok := settings[key].([]map[string]interface{}); ok {
|
||||
for _, item := range existingMaps {
|
||||
eventsList = append(eventsList, item)
|
||||
}
|
||||
}
|
||||
if len(eventsList) > maxActivityEvents {
|
||||
eventsList = eventsList[:maxActivityEvents]
|
||||
}
|
||||
settings[key] = eventsList
|
||||
return a.storage.WritePluginSettings(pluginID, settings)
|
||||
}
|
||||
|
||||
func activityFromEvent(event events.Event) map[string]interface{} {
|
||||
@@ -479,6 +753,24 @@ func activityFromEvent(event events.Event) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) activityFromEvent(event events.Event) map[string]interface{} {
|
||||
activity := activityFromEvent(event)
|
||||
workspaceRoot := firstPayloadText(activity, "workspaceRootPath")
|
||||
if workspaceRoot == "" || a == nil || a.workspace == nil {
|
||||
activity["sessionScope"] = map[string]interface{}{"kind": "unassigned"}
|
||||
return activity
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot)
|
||||
if err != nil {
|
||||
activity["sessionScope"] = map[string]interface{}{"kind": "unassigned"}
|
||||
return activity
|
||||
}
|
||||
activity["workspaceId"] = identity.WorkspaceID
|
||||
activity["workspaceRootPath"] = identity.RootPath
|
||||
activity["sessionScope"] = map[string]interface{}{"kind": "workspace", "workspaceId": identity.WorkspaceID}
|
||||
return activity
|
||||
}
|
||||
|
||||
func eventPayloadMap(payload interface{}) map[string]interface{} {
|
||||
switch value := payload.(type) {
|
||||
case map[string]interface{}:
|
||||
@@ -1014,6 +1306,40 @@ func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
|
||||
return data
|
||||
}
|
||||
|
||||
// ReadPluginDataNDJSON reads append-only plugin data without exposing the
|
||||
// underlying vault path to plugin frontends.
|
||||
func (a *App) ReadPluginDataNDJSON(pluginID, name string) []map[string]interface{} {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
log.Printf("[api] ReadPluginDataNDJSON(%s, %s): %v", pluginID, name, err)
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
if a.storage == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
data, err := a.storage.ReadPluginDataNDJSON(pluginID, name)
|
||||
if err != nil {
|
||||
log.Printf("[api] ReadPluginDataNDJSON(%s, %s): %v", pluginID, name, err)
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// WritePluginDataNDJSON replaces append-only data after an explicit user
|
||||
// action, such as clearing activity history.
|
||||
func (a *App) WritePluginDataNDJSON(pluginID, name string, data []map[string]interface{}) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.storage == nil {
|
||||
return "storage not initialized"
|
||||
}
|
||||
if err := a.storage.WritePluginDataNDJSON(pluginID, name, data); err != nil {
|
||||
log.Printf("[api] WritePluginDataNDJSON(%s, %s): %v", pluginID, name, err)
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WritePluginDataJSON writes a named JSON data file for a plugin.
|
||||
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
@@ -1029,6 +1355,44 @@ func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interfa
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReplacePluginNotifications replaces one plugin's desired native notification
|
||||
// schedules. Plugins must declare both the capability and permission.
|
||||
func (a *App) ReplacePluginNotifications(pluginID string, requests []notifications.Request) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "notifications.schedule"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/notifications/v1"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.notifications == nil {
|
||||
return "notification scheduler not initialized"
|
||||
}
|
||||
if err := a.notifications.Replace(pluginID, requests); err != nil {
|
||||
log.Printf("[api] ReplacePluginNotifications(%s): %v", pluginID, err)
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ClearPluginNotifications removes every native notification schedule owned by
|
||||
// one plugin.
|
||||
func (a *App) ClearPluginNotifications(pluginID string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "notifications.schedule"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/notifications/v1"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.notifications == nil {
|
||||
return "notification scheduler not initialized"
|
||||
}
|
||||
if err := a.notifications.Clear(pluginID); err != nil {
|
||||
log.Printf("[api] ClearPluginNotifications(%s): %v", pluginID, err)
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListVaultFiles lists a vault-relative directory for a plugin with files.read.
|
||||
func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
|
||||
@@ -1321,6 +1685,22 @@ func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// OpenExternalURL opens an HTTP(S) URL through the platform browser opener.
|
||||
// This deliberately bypasses OS file associations for InternetShortcut files.
|
||||
func (a *App) OpenExternalURL(pluginID, rawURL string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "invalid HTTP(S) URL"
|
||||
}
|
||||
if err := a.externalOpenService().OpenPath(parsed.String()); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ShowVaultPathInFolder reveals a vault-relative file or folder in the OS file manager.
|
||||
func (a *App) ShowVaultPathInFolder(pluginID, relativePath string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
|
||||
@@ -1814,6 +2194,7 @@ func (a *App) GetAppSettings() map[string]interface{} {
|
||||
"theme": cfg.Theme,
|
||||
"language": cfg.Language,
|
||||
"devMode": cfg.DevMode,
|
||||
"debug": a.debug,
|
||||
"userPluginsDir": cfg.UserPluginsDir,
|
||||
"lastOpenedAt": cfg.LastOpenedAt,
|
||||
}
|
||||
@@ -1950,6 +2331,29 @@ func (a *App) ListWorkspaceTemplates() ([]workspace.WorkspaceTemplate, string) {
|
||||
return a.workspace.ListWorkspaceTemplates(), ""
|
||||
}
|
||||
|
||||
// ListWorkspaceIdentities returns durable workspace identities for relation-aware plugins.
|
||||
func (a *App) ListWorkspaceIdentities() ([]workspace.WorkspaceIdentity, string) {
|
||||
if a.workspace == nil {
|
||||
return nil, "workspace not initialized"
|
||||
}
|
||||
identities, err := a.workspace.ListWorkspaceIdentities()
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return identities, ""
|
||||
}
|
||||
|
||||
// RepairWorkspaceIdentity resolves a duplicated workspace marker without moving relations.
|
||||
func (a *App) RepairWorkspaceIdentity(keepName, regenerateName string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
if err := a.workspace.RepairWorkspaceIdentity(keepName, regenerateName); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CreateWorkspace creates a top-level physical workspace folder.
|
||||
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
|
||||
if a.workspace == nil {
|
||||
@@ -1961,6 +2365,7 @@ func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, str
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceCreatedEventName, map[string]interface{}{
|
||||
"operation": "create",
|
||||
"workspaceId": ws.ID,
|
||||
"workspaceRootPath": ws.RootPath,
|
||||
"workspaceName": ws.Name,
|
||||
"templateId": templateID,
|
||||
@@ -1976,8 +2381,13 @@ func (a *App) RenameWorkspace(oldName, newName string) string {
|
||||
if err := a.workspace.RenameWorkspace(oldName, newName); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(newName)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceRenamedEventName, map[string]interface{}{
|
||||
"operation": "rename",
|
||||
"workspaceId": identity.WorkspaceID,
|
||||
"workspaceRootPath": newName,
|
||||
"workspaceName": newName,
|
||||
"previousWorkspaceRootPath": oldName,
|
||||
@@ -1997,6 +2407,7 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceTrashedEventName, map[string]interface{}{
|
||||
"operation": "trash",
|
||||
"workspaceId": result.WorkspaceID,
|
||||
"workspaceRootPath": name,
|
||||
"workspaceName": name,
|
||||
"trashId": result.TrashID,
|
||||
@@ -2006,6 +2417,47 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
||||
return result, ""
|
||||
}
|
||||
|
||||
// RestoreWorkspaceTrash restores a trashed workspace and publishes its durable identity.
|
||||
func (a *App) RestoreWorkspaceTrash(trashID, targetName string) (workspace.Workspace, string) {
|
||||
if a.workspace == nil {
|
||||
return workspace.Workspace{}, "workspace not initialized"
|
||||
}
|
||||
restored, err := a.workspace.RestoreWorkspaceTrash(trashID, targetName)
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, err.Error()
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceRestoredEventName, map[string]interface{}{
|
||||
"operation": "restore",
|
||||
"workspaceId": restored.ID,
|
||||
"workspaceRootPath": restored.RootPath,
|
||||
"workspaceName": restored.Name,
|
||||
"trashId": trashID,
|
||||
})
|
||||
return restored, ""
|
||||
}
|
||||
|
||||
// PurgeWorkspaceTrash permanently removes a trashed workspace and publishes its former identity.
|
||||
func (a *App) PurgeWorkspaceTrash(trashID string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceTrashIdentity(trashID)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if err := a.workspace.PurgeWorkspaceTrash(trashID); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspacePurgedEventName, map[string]interface{}{
|
||||
"operation": "purge",
|
||||
"workspaceId": identity.WorkspaceID,
|
||||
"workspaceRootPath": identity.RootPath,
|
||||
"workspaceName": identity.RootPath,
|
||||
"trashId": trashID,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetWorkspaceMetadata returns metadata or a generic fallback for a workspace.
|
||||
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
|
||||
if a.workspace == nil {
|
||||
@@ -2039,7 +2491,13 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(node.Name)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": identity.WorkspaceID,
|
||||
"workspaceId": identity.WorkspaceID,
|
||||
"name": node.Name,
|
||||
"rootPath": node.RootPath,
|
||||
}
|
||||
|
||||
+499
-12
@@ -14,12 +14,15 @@ import (
|
||||
"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"
|
||||
@@ -27,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()
|
||||
|
||||
@@ -77,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)
|
||||
@@ -134,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) {
|
||||
@@ -593,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"])
|
||||
}
|
||||
@@ -619,6 +833,51 @@ 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 {
|
||||
@@ -661,6 +920,9 @@ func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
|
||||
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{}{
|
||||
@@ -683,11 +945,154 @@ func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
|
||||
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 {
|
||||
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{
|
||||
@@ -1607,11 +2012,14 @@ func TestAppSettingsLanguageAPI(t *testing.T) {
|
||||
if err := settings.Update(&appsettings.Config{Theme: "light", DevMode: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{appSettings: settings}
|
||||
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)
|
||||
}
|
||||
@@ -1826,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)
|
||||
}
|
||||
@@ -1836,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)
|
||||
}
|
||||
@@ -1845,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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -59,6 +64,8 @@ type Options struct {
|
||||
ReceiverToken string
|
||||
Available func() bool
|
||||
Persist func(events.Event) error
|
||||
ActivityAvailable func() bool
|
||||
PersistActivity func(events.Event) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -107,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...)
|
||||
}
|
||||
@@ -141,6 +165,18 @@ func (r *Receiver) SetPersistence(available func() bool, persist func(events.Eve
|
||||
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")
|
||||
@@ -181,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
|
||||
@@ -256,6 +296,59 @@ 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{}
|
||||
@@ -370,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")
|
||||
@@ -450,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) {
|
||||
|
||||
@@ -238,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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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": "" }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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}
|
||||
@@ -234,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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
exec /opt/verstak/verstak-desktop "$@"
|
||||
Executable
+13
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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 |
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "ROOT=%~dp0"
|
||||
|
||||
start "" "%ROOT%verstak-desktop.exe" %*
|
||||
Executable
+31
@@ -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"
|
||||
Executable
+75
@@ -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."
|
||||
@@ -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]"
|
||||
|
||||
Executable
+123
@@ -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"
|
||||
Executable
+44
@@ -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"
|
||||
Executable
+35
@@ -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"
|
||||
Executable
+81
@@ -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
|
||||
Executable
+26
@@ -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"
|
||||
Executable
+21
@@ -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"
|
||||
Executable
+45
@@ -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"
|
||||
Executable
+100
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user