Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf965e8bf3 | ||
|
|
6eeebd39ad | ||
|
|
3c1f2c7177 | ||
|
|
a9576ac141 | ||
|
|
fb4bb2a0c3 | ||
|
|
76bc312bb9 | ||
|
|
d353b5b335 | ||
|
|
3d78e0deaf | ||
|
|
41aeefc182 | ||
|
|
f9b20d51ff | ||
|
|
8be107013d | ||
|
|
ddcd799f16 | ||
|
|
7b7b892332 | ||
|
|
be3755e50e | ||
|
|
dbfc4e8d0a | ||
|
|
cd44ee3866 | ||
|
|
a0262b97b3 |
@@ -7,7 +7,7 @@
|
||||
### Keep the context of your work together — locally.
|
||||
|
||||
Files, notes, links, browser captures, activity and work history
|
||||
in one extensible local-first workspace.
|
||||
in one extensible local-first workbench.
|
||||
|
||||
**English** · [Русский](README.ru.md)
|
||||
|
||||
@@ -29,7 +29,7 @@ in one extensible local-first workspace.
|
||||
|
||||
Verstak is a local-first workbench that keeps the context around your work in one place.
|
||||
|
||||
A **workspace** can represent almost anything:
|
||||
A **Deal** can represent almost anything:
|
||||
|
||||
* a software project;
|
||||
* a client or customer;
|
||||
@@ -47,29 +47,29 @@ 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. |
|
||||
| Overview: return to a Deal and see its recent work, captures and items that need attention. | Files: manage ordinary folders and documents inside a Deal. |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Notes: keep Markdown notes next to the workspace they describe. | Journal: record a completed session and retain its context. |
|
||||
| Notes: keep Markdown notes next to the Deal 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. |
|
||||
| **Deals** | 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. |
|
||||
| **Todo** | Keep optional task lists inside individual Deals 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. |
|
||||
| **Secrets** | Keep credentials and access information connected to the relevant Deal. |
|
||||
| **Templates** | Create repeatable Deal 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. |
|
||||
|
||||
@@ -124,7 +124,7 @@ Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows
|
||||
|
||||
## Background and tray mode
|
||||
|
||||
Closing the main window keeps Verstak running in the system tray. Use **Show Verstak** to bring it back and **Quit** to exit the application completely. This lets scheduled Todo reminders continue while the window is hidden.
|
||||
After the tray icon is ready, closing the main window keeps Verstak running in the system tray. One left click restores and focuses the window; a right click opens the native **Show Verstak** and **Quit** menu. **Quit** exits the application completely. If the tray cannot start, closing the window exits normally rather than leaving an unreachable process. Scheduled Todo reminders continue while the window is hidden.
|
||||
|
||||
### Verify a download
|
||||
|
||||
@@ -151,9 +151,9 @@ Documents/
|
||||
|
||||
Your files and notes remain ordinary files that can be accessed without Verstak.
|
||||
|
||||
### 2. Create your first workspace
|
||||
### 2. Create your first Deal
|
||||
|
||||
Inside the vault, create a workspace for a project, client, server, device or another area of work.
|
||||
Inside the vault, create a Deal for a project, client, server, device or another area of work.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -167,7 +167,7 @@ Training course
|
||||
|
||||
### 3. Add context
|
||||
|
||||
Open the workspace and add the information needed to return to it later:
|
||||
Open the Deal and add the information needed to return to it later:
|
||||
|
||||
* notes and decisions;
|
||||
* documents and source files;
|
||||
@@ -232,9 +232,9 @@ Each vault is connected separately. The local vault remains the primary copy of
|
||||
* Node.js 20 or newer with npm;
|
||||
* Python 3;
|
||||
* Git;
|
||||
* ImageMagick (`magick`) to generate the branded tray and application icons;
|
||||
* Wails v2 build dependencies;
|
||||
* WebKitGTK development packages on Linux.
|
||||
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
|
||||
|
||||
See the
|
||||
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/)
|
||||
@@ -245,15 +245,15 @@ for distribution-specific dependencies.
|
||||
The desktop, SDK and official plugin repositories must be cloned as sibling directories:
|
||||
|
||||
```text
|
||||
verstak-workspace/
|
||||
verstak-source/
|
||||
├── verstak/
|
||||
├── verstak-sdk/
|
||||
└── verstak-official-plugins/
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir verstak-workspace
|
||||
cd verstak-workspace
|
||||
mkdir verstak-source
|
||||
cd verstak-source
|
||||
|
||||
git clone https://github.com/mirivlad/verstak.git
|
||||
git clone https://github.com/mirivlad/verstak-sdk.git
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
### Рабочий контекст остаётся рядом — и хранится локально.
|
||||
|
||||
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
|
||||
в одном расширяемом рабочем пространстве.
|
||||
в одной расширяемой локальной среде.
|
||||
|
||||
[English](README.md) · **Русский**
|
||||
|
||||
@@ -49,11 +49,11 @@
|
||||
|
||||
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## Основные возможности
|
||||
|
||||
@@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
|
||||
|
||||
## Фоновая работа и трей
|
||||
|
||||
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
|
||||
После готовности значка в трее закрытие главного окна не завершает Верстак. Один левый клик возвращает и фокусирует окно, правый клик открывает нативное меню **«Показать Верстак»** и **«Выйти»**. **«Выйти»** полностью завершает приложение. Если трей не удалось запустить, закрытие окна завершает приложение обычным образом и не оставляет недоступный процесс. Пока окно скрыто, напоминания Todo продолжают работать.
|
||||
|
||||
### Проверка скачанного файла
|
||||
|
||||
@@ -232,9 +232,9 @@ sha256sum -c SHA256SUMS --ignore-missing
|
||||
* Node.js 20 или новее и npm;
|
||||
* Python 3;
|
||||
* Git;
|
||||
* ImageMagick (`magick`) для генерации фирменных значков трея и приложения;
|
||||
* зависимости для сборки Wails v2;
|
||||
* пакеты разработки WebKitGTK в Linux.
|
||||
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
|
||||
|
||||
Зависимости для конкретного дистрибутива перечислены в
|
||||
[документации Wails](https://wails.io/docs/gettingstarted/installation/).
|
||||
@@ -244,15 +244,15 @@ sha256sum -c SHA256SUMS --ignore-missing
|
||||
Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
|
||||
|
||||
```text
|
||||
verstak-workspace/
|
||||
verstak-source/
|
||||
├── verstak/
|
||||
├── verstak-sdk/
|
||||
└── verstak-official-plugins/
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir verstak-workspace
|
||||
cd verstak-workspace
|
||||
mkdir verstak-source
|
||||
cd verstak-source
|
||||
|
||||
git clone https://github.com/mirivlad/verstak.git
|
||||
git clone https://github.com/mirivlad/verstak-sdk.git
|
||||
|
||||
@@ -464,7 +464,7 @@ contributions summary.
|
||||
Conflicts are rejected unless `options.overwrite` is true.
|
||||
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
|
||||
internal trash. This operation cannot be undone.
|
||||
- UI policy: the workspace `Files` plugin displays only live files and folders.
|
||||
- UI policy: the Deal `Files` plugin displays only live files and folders.
|
||||
The global official `verstak.trash` plugin owns listing, restoring, filtering,
|
||||
and permanently deleting deleted items across the vault.
|
||||
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
|
||||
@@ -635,7 +635,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun
|
||||
|
||||
1. Unregister contributions всех plugins предыдущего discovery.
|
||||
2. Unregister all non-core capabilities.
|
||||
3. Re-register core capabilities + vault + workspace (если открыт).
|
||||
3. Re-register core capabilities + vault + Deal manager (если открыт).
|
||||
4. Re-scan discovery directories и повторить capability resolution.
|
||||
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
|
||||
6. Update plugins list.
|
||||
@@ -838,51 +838,52 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
|
||||
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
|
||||
- `./scripts/build.sh` — ✅
|
||||
|
||||
## Workspace Core Capability
|
||||
## Дело: core capability
|
||||
|
||||
Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem
|
||||
является source of truth для списка workspaces.
|
||||
Дело — это физическая папка верхнего уровня внутри vault root. Filesystem
|
||||
является source of truth для списка Дел.
|
||||
|
||||
Пример:
|
||||
|
||||
```
|
||||
<vault>/
|
||||
Workspace/
|
||||
Deal/
|
||||
Notes/
|
||||
Project/
|
||||
ClientA/
|
||||
.verstak/
|
||||
```
|
||||
|
||||
Нет единого `<vault>/Workspace/` контейнера для всех workspaces. Папка
|
||||
`Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются
|
||||
такими же workspace на том же уровне.
|
||||
Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть
|
||||
обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же
|
||||
уровне.
|
||||
|
||||
### Хранение
|
||||
|
||||
Workspace existence/list хранится только в filesystem:
|
||||
Существование и список Дел хранятся только в filesystem:
|
||||
|
||||
- `ListWorkspaces()` читает top-level directories из vault root.
|
||||
- `.verstak`, reserved/internal directories, top-level files и symlinks не
|
||||
считаются workspaces.
|
||||
- `.verstak/workspace*.json` не является source of truth для списка workspaces.
|
||||
- Нет persisted workspace path mapping и нет virtual workspace tree, которое
|
||||
считаются Делами.
|
||||
- `.verstak/workspace*.json` не является source of truth для списка Дел.
|
||||
- Нет virtual workspace tree, которое
|
||||
мапится на произвольные папки.
|
||||
|
||||
`.verstak` может хранить только metadata, которая не заменяет filesystem:
|
||||
|
||||
- UI state: selected workspace, expanded folders, sort/pin state, preferences.
|
||||
- UI state: выбранное Дело, expanded folders, sort/pin state, preferences.
|
||||
- Semantic snapshot: applied template snapshot, enabled feature areas, exact
|
||||
`workspaceTools`, and folder conventions.
|
||||
|
||||
Template snapshot копируется в metadata при создании workspace. Workspace
|
||||
identity при этом остаётся именем top-level folder; `metadata.workspaceName`
|
||||
является presentation/snapshot field, not canonical identity. Если сохранённое
|
||||
значение расходится с именем папки, runtime возвращает canonical `workspaceName`
|
||||
равным имени папки без filesystem side effects.
|
||||
При создании Дела runtime генерирует UUID и сохраняет его в
|
||||
`.verstak/workspace.json`. `workspaceId` — постоянная личность Дела;
|
||||
`workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields.
|
||||
Переименование обновляет путь, но не ID. Новая папка с прежним именем получает
|
||||
другой ID и не может перехватить старые привязки.
|
||||
|
||||
```json
|
||||
{
|
||||
"workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
|
||||
"workspaceName": "Project",
|
||||
"createdFromTemplate": {
|
||||
"templateId": "project",
|
||||
@@ -921,25 +922,25 @@ identity при этом остаётся именем top-level folder; `metada
|
||||
}
|
||||
```
|
||||
|
||||
Если original template удалён или изменён позже, существующий workspace
|
||||
Если original template удалён или изменён позже, существующее Дело
|
||||
открывается по сохранённому snapshot и не мутирует автоматически. Template
|
||||
update/migration может быть только явной future feature. Если metadata
|
||||
отсутствует, workspace открывается как generic workspace минимум с `files: true`.
|
||||
отсутствует, Дело открывается как generic Deal минимум с `files: true`.
|
||||
|
||||
### API
|
||||
|
||||
- `ListWorkspaces()` — список top-level physical folders.
|
||||
- `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`.
|
||||
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
|
||||
metadata и `workspaceTools`.
|
||||
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
|
||||
template один раз, сохранить snapshot metadata.
|
||||
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder
|
||||
и обновить metadata key/name.
|
||||
- `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal
|
||||
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level папку
|
||||
Дела и обновить metadata name, не меняя `workspaceId`.
|
||||
- `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal
|
||||
trash policy.
|
||||
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
|
||||
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
|
||||
существование workspace.
|
||||
существование Дела.
|
||||
|
||||
Deprecated compatibility APIs:
|
||||
|
||||
@@ -949,7 +950,7 @@ Deprecated compatibility APIs:
|
||||
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
|
||||
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
|
||||
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
|
||||
selected top-level workspace UI state.
|
||||
selected top-level Deal UI state.
|
||||
|
||||
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
|
||||
должны создавать или сохранять nested workspace tree и не должны восстанавливать
|
||||
@@ -957,32 +958,33 @@ Deprecated compatibility APIs:
|
||||
|
||||
### Capability
|
||||
|
||||
`verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован.
|
||||
`verstak/core/workspace/v1` — техническое имя capability; оно регистрируется
|
||||
только когда vault открыт и Deal manager инициализирован.
|
||||
|
||||
### Правила
|
||||
|
||||
- Workspace name — один safe folder name, не path.
|
||||
- Имя Дела — один safe folder name, не path.
|
||||
- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
|
||||
`.verstak`, reserved/internal names, symlink workspaces, conflicts.
|
||||
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки
|
||||
`.verstak`, reserved/internal names, symlink Дел, conflicts.
|
||||
- WorkspaceItems получают техническое поле `workspaceRootPath`, равное имени top-level папки
|
||||
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
|
||||
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
|
||||
`Project/docs/file.md`, `Test/readme.md`.
|
||||
- Notes are ordinary Markdown files under `<workspace>/Notes/`; нет
|
||||
- Notes are ordinary Markdown files under `<Deal>/Notes/`; нет
|
||||
`.verstak/notes`, UUID note storage или второго source of truth для note
|
||||
content.
|
||||
|
||||
### Lifecycle Events
|
||||
|
||||
Runtime publishes workspace lifecycle events after successful operations:
|
||||
Runtime publishes lifecycle events Дел после успешных операций:
|
||||
|
||||
- `workspace.created` — payload includes `operation: "create"`,
|
||||
- `workspace.created` — payload includes `operation: "create"`, `workspaceId`,
|
||||
`workspaceRootPath`, `workspaceName`, and optional `templateId`.
|
||||
- `workspace.renamed` — payload includes `operation: "rename"`, new
|
||||
`workspaceRootPath` / `workspaceName`, and previous workspace fields.
|
||||
- `workspace.trashed` — payload includes `operation: "trash"`,
|
||||
- `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`,
|
||||
new `workspaceRootPath` / `workspaceName`, and previous path fields.
|
||||
- `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`,
|
||||
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
|
||||
- `workspace.selected` — payload includes `operation: "select"`,
|
||||
- `workspace.selected` — payload includes `operation: "select"`, `workspaceId`,
|
||||
`workspaceRootPath`, and `workspaceName`.
|
||||
|
||||
Official Activity subscribes to these events and stores them through the normal
|
||||
@@ -990,11 +992,11 @@ activity provider path.
|
||||
|
||||
### UI
|
||||
|
||||
Workspace list в sidebar:
|
||||
- Flat list of top-level workspace folders.
|
||||
- Create workspace, rename workspace, trash workspace.
|
||||
- Selection is stored as selected workspace name.
|
||||
- No expand/collapse workspace tree and no case/folder node creation in core.
|
||||
Список Дел в sidebar:
|
||||
- Flat list of top-level папок Дел.
|
||||
- Создать Дело, переименовать Дело, переместить Дело в корзину.
|
||||
- Selection is stored as selected Deal name.
|
||||
- No expand/collapse tree and no case/folder node creation in core.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Workspace Templates
|
||||
# Deal Templates
|
||||
|
||||
Workspace templates choose which dynamic plugin workspace tabs are visible when a
|
||||
new top-level workspace is created. They do not enable or disable plugins for the
|
||||
Deal templates choose which dynamic plugin Deal tabs are visible when a new
|
||||
top-level Deal is created. They do not enable or disable plugins for the
|
||||
whole vault and do not affect global sidebar tools.
|
||||
|
||||
## Built-in templates
|
||||
|
||||
| Template | Workspace tabs |
|
||||
| Template | Deal tabs |
|
||||
| --- | --- |
|
||||
| General | Notes, Files, Journal, Activity, Browser Inbox |
|
||||
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
|
||||
@@ -14,27 +14,27 @@ whole vault and do not affect global sidebar tools.
|
||||
| Admin | Notes, Files, Secrets, Todos, Journal |
|
||||
| Minimal | Notes, Files |
|
||||
|
||||
The create-workspace modal displays the selected template description and its
|
||||
The create-Deal modal displays the selected template description and its
|
||||
included plugin tabs before the folder is created.
|
||||
|
||||
## Metadata and compatibility
|
||||
|
||||
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
|
||||
snapshot contains the template id, name, version, applied time, and an ordered
|
||||
`workspaceTools` list of plugin IDs. Existing workspace metadata without
|
||||
`workspaceTools` remains compatible: its workspace continues to show all globally
|
||||
enabled workspace plugins rather than unexpectedly hiding tabs.
|
||||
`workspaceTools` list of plugin IDs. Existing Deal metadata without
|
||||
`workspaceTools` remains compatible: its Deal continues to show all globally
|
||||
enabled Deal plugins rather than unexpectedly hiding tabs.
|
||||
|
||||
Templates are applied once. Editing the built-in catalog or creating another
|
||||
workspace with a different template never changes an existing workspace snapshot.
|
||||
Deal with a different template never changes an existing Deal snapshot.
|
||||
There is no template editor or post-creation template switcher yet.
|
||||
|
||||
## Global tools and unavailable plugins
|
||||
|
||||
Template visibility applies only to `workspaceItems` in the selected workspace.
|
||||
Template visibility applies only to `workspaceItems` in the selected Deal.
|
||||
Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
|
||||
remain available according to the normal plugin enablement state.
|
||||
|
||||
The workspace host intersects the snapshot with dynamically discovered, globally
|
||||
The Deal host intersects the snapshot with dynamically discovered, globally
|
||||
enabled plugin contributions. If a template references a plugin that is missing or
|
||||
disabled, that tab is simply absent; the other template tabs remain usable.
|
||||
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -6,12 +6,13 @@
|
||||
|
||||
**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.
|
||||
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `fyne.io/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
|
||||
|
||||
## Global constraints
|
||||
|
||||
- Support Windows and Linux only; no background daemon after an explicit Quit.
|
||||
- Closing a window hides it; only tray **Quit** ends the process.
|
||||
- Closing a window hides it only after the tray reports ready; otherwise normal
|
||||
window close ends the process.
|
||||
- `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
|
||||
- Plugins cannot receive Wails runtime access.
|
||||
- Keep Wails generated bindings out of commits.
|
||||
@@ -231,7 +232,12 @@ 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/`.
|
||||
Use `fyne.io/systray`. The production adapter calls `RunWithExternalLoop` so the
|
||||
Windows native message loop runs alongside Wails, embeds a multi-resolution
|
||||
ICO on Windows and PNG on Linux, routes one left click to `app.ShowWindow`, and
|
||||
leaves the platform-native right-click menu active. `main.go` registers
|
||||
`OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls
|
||||
`app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
|
||||
|
||||
- [ ] **Step 4: Verify and commit**
|
||||
|
||||
@@ -239,7 +245,7 @@ Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonbloc
|
||||
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'
|
||||
! rg -n 'getlantern|appindicator' go.mod go.sum packaging scripts/build.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
@@ -265,7 +271,8 @@ git push mirror main
|
||||
|
||||
- [ ] **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.
|
||||
Add assertions that the Linux build guidance, Debian metadata, and AppImage
|
||||
packager no longer require the removed AppIndicator backend.
|
||||
|
||||
- [ ] **Step 2: Confirm red**
|
||||
|
||||
@@ -275,7 +282,9 @@ 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.
|
||||
Keep Debian and AppImage focused on the Wails/WebKitGTK runtime. Do not add a
|
||||
tray-specific AppIndicator dependency. Keep the Windows system-WebView2 policy
|
||||
unchanged.
|
||||
|
||||
- [ ] **Step 4: Install the supplied public README sources**
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Native Notifications and System Tray Design
|
||||
|
||||
**Status:** approved for implementation on 2026-07-14
|
||||
**Status:** implemented; tray reliability update recorded on 2026-07-15
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -19,27 +19,33 @@ must work in the portable Windows archive, Debian package, and AppImage.
|
||||
|
||||
## Tray behavior
|
||||
|
||||
On Windows and Linux, a tray icon is registered before the Wails event loop
|
||||
starts. Its menu contains exactly two actions:
|
||||
On Windows and Linux, a tray icon is initialized after Wails reaches
|
||||
`OnDomReady`. Its menu contains exactly two actions:
|
||||
|
||||
1. **Show Verstak** — shows and focuses the existing main window.
|
||||
2. **Quit** — exits the process deliberately.
|
||||
|
||||
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.
|
||||
One left click restores and focuses the existing main window. A native right
|
||||
click opens the menu. Closing the main window with its window-manager close
|
||||
control hides the window only after the tray has successfully initialized; it
|
||||
then keeps the process, plugins, local browser receiver, and reminder scheduler
|
||||
alive. If tray initialization fails or the native message loop exits, the
|
||||
ordinary close path exits normally rather than leaving an unreachable process.
|
||||
The quit action allows the close lifecycle to finish and exits normally.
|
||||
|
||||
The app has a single-instance lock. If a user launches the executable while an
|
||||
instance is hidden in the tray, the existing instance shows its window instead
|
||||
of creating a second process.
|
||||
|
||||
The implementation uses `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.
|
||||
The implementation uses `fyne.io/systray` through a small
|
||||
`internal/shell/tray` adapter. `RunWithExternalLoop` starts the Windows native
|
||||
message loop without making Wails relinquish ownership of its GUI lifecycle.
|
||||
The icon is a source-controlled multi-resolution ICO on Windows (16, 20, 24,
|
||||
32, 48, and 256 pixels with transparency) and a PNG on Linux. Both are embedded
|
||||
in the binary, so a clean build does not depend on ignored Wails-generated
|
||||
files. Tray readiness is published only after icon, tooltip, and menu creation
|
||||
all succeed; lifecycle diagnostics are logged for startup, readiness, clicks,
|
||||
failure fallback, and shutdown.
|
||||
|
||||
## Notification capability and permission
|
||||
|
||||
@@ -116,16 +122,14 @@ 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`.
|
||||
`fyne.io/systray` supplies the Windows message loop and uses the session D-Bus
|
||||
on Linux. It does not require the removed AppIndicator development or runtime
|
||||
package. The Windows release build still uses `x86_64-w64-mingw32-gcc` for the
|
||||
Wails application itself.
|
||||
|
||||
The existing AppImage packager traverses `ldd` for the desktop executable and
|
||||
copies non-glibc runtime libraries. Its verification is extended to prove that
|
||||
the appindicator library is present in the AppDir when the tray implementation
|
||||
is compiled in.
|
||||
copies non-glibc runtime libraries; it does not require a tray-specific shared
|
||||
library.
|
||||
|
||||
## Public README and product screenshots
|
||||
|
||||
@@ -156,15 +160,19 @@ 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.
|
||||
- close policy: ordinary close hides only after tray readiness, while explicit
|
||||
quit permits shutdown;
|
||||
- tray controller action wiring, readiness/failure fallback, idempotent stop,
|
||||
left-click reveal, and second-instance window reveal;
|
||||
- true multi-resolution Windows ICO data, Linux PNG data, and Linux/Windows
|
||||
build and package dependency expectations.
|
||||
|
||||
Manual smoke tests are required because neither unit tests nor Playwright can
|
||||
assert a real desktop notification area or OS toast:
|
||||
|
||||
1. On Linux and Windows, start Verstak, close its window, use the tray menu to
|
||||
reveal it, and use **Quit** to terminate it.
|
||||
1. On Linux and Windows, start Verstak, verify the tray icon and tooltip, use
|
||||
one left click to reveal the window, use the right-click menu to reveal it,
|
||||
close the window, and use **Quit** to terminate it.
|
||||
2. Set a Todo reminder for a near future time, hide the window in the tray, and
|
||||
observe one native notification.
|
||||
3. Quit before a future reminder, relaunch after it expires, and observe one
|
||||
|
||||
@@ -106,7 +106,7 @@ test.describe('Activity workflow', () => {
|
||||
await expect(candidateSection).toContainText('Possible journal entries');
|
||||
const candidate = candidateSection.locator('[data-work-session-candidate]');
|
||||
await expect(candidate).toHaveCount(1);
|
||||
await expect(candidate).toContainText('Workspace: Project');
|
||||
await expect(candidate).toContainText('Deal: Project');
|
||||
await expect(candidate).toContainText('Estimated duration: 10 min');
|
||||
await expect(candidate).toContainText('Activities: 2');
|
||||
await expect(candidate).not.toContainText('Project work on');
|
||||
@@ -142,7 +142,7 @@ test.describe('Activity workflow', () => {
|
||||
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
|
||||
const journal = page.locator('.journal-root');
|
||||
await expect(journal).toBeVisible({ timeout: 10000 });
|
||||
await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project');
|
||||
await expect(journal.locator('[data-journal-candidate]')).toContainText('Deal: Project');
|
||||
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min');
|
||||
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
|
||||
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
|
||||
|
||||
@@ -175,10 +175,10 @@ test.describe('F: Default Editor Plugin', () => {
|
||||
const workbench = page.locator('.workbench-host');
|
||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||
const title = workbench.locator('.workbench-title');
|
||||
await expect(title).toHaveText('Docs/readme.md');
|
||||
await expect(title).toHaveText('readme.md');
|
||||
});
|
||||
|
||||
test('open .md with notes context routes to highest-priority provider', async ({ page }) => {
|
||||
test('opening a note uses its title rather than its technical path', async ({ page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||
kind: 'vault-file',
|
||||
@@ -193,7 +193,7 @@ test.describe('F: Default Editor Plugin', () => {
|
||||
const workbench = page.locator('.workbench-host');
|
||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||
const title = workbench.locator('.workbench-title');
|
||||
await expect(title).toHaveText('Notes/Overview.md');
|
||||
await expect(title).toHaveText('Overview');
|
||||
});
|
||||
|
||||
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
|
||||
|
||||
@@ -513,7 +513,7 @@ test.describe('G: Files Plugin', () => {
|
||||
|
||||
const workbench = page.locator('.workbench-host');
|
||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||
await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md');
|
||||
await expect(workbench.locator('.workbench-title')).toHaveText('readme.md');
|
||||
|
||||
const preview = page.locator('.de-preview');
|
||||
await expect(preview).toBeVisible({ timeout: 10000 });
|
||||
@@ -535,7 +535,7 @@ test.describe('G: Files Plugin', () => {
|
||||
|
||||
const workbench = page.locator('.workbench-host');
|
||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
|
||||
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
|
||||
});
|
||||
|
||||
test('files plugin card shows openProviders in contributions', async ({ page }) => {
|
||||
|
||||
@@ -25,9 +25,23 @@ test.describe('Desktop localization', () => {
|
||||
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
|
||||
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
|
||||
|
||||
const project = page.locator('.wt-node').filter({ hasText: 'Project' });
|
||||
await project.locator('button[title="Переименовать Дело"]').click();
|
||||
await expect(page.locator('button[title="Сохранить новое название"]')).toHaveText('Сохранить');
|
||||
await page.locator('.wt-rename').press('Escape');
|
||||
|
||||
await page.locator('[data-settings-menu-button]').click();
|
||||
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
|
||||
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
|
||||
await page.locator('[data-settings-action="plugin-manager"]').click();
|
||||
const pluginManager = page.locator('.plugin-manager');
|
||||
await expect(pluginManager).toContainText('Зарегистрировано возможностей:');
|
||||
await expect(pluginManager.locator('.registry-section')).toContainText('Реестр возможностей');
|
||||
await expect(pluginManager.locator('.registry-section')).toContainText('Возможность');
|
||||
await expect(pluginManager.locator('.plugin-filters')).toContainText('Фильтры');
|
||||
await expect(pluginManager.locator('[data-plugin-filter-results]')).toContainText('Показано плагинов:');
|
||||
await expect(pluginManager.locator('[data-plugin-filter="status"]')).toContainText('Выключенные');
|
||||
await expect(pluginManager).not.toContainText('Workspace-scoped');
|
||||
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
@@ -58,7 +58,7 @@ test.describe('D: Plugin API bridge', () => {
|
||||
|
||||
const workbench = page.locator('.workbench-host');
|
||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
|
||||
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
|
||||
});
|
||||
|
||||
test('workbench routes markdown files to default-editor provider', async ({ page }) => {
|
||||
@@ -76,7 +76,7 @@ test.describe('D: Plugin API bridge', () => {
|
||||
const workbench = page.locator('.workbench-host');
|
||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||
const title = workbench.locator('.workbench-title');
|
||||
await expect(title).toHaveText('Docs/readme.md');
|
||||
await expect(title).toHaveText('readme.md');
|
||||
});
|
||||
|
||||
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
|
||||
@@ -91,8 +91,10 @@ test.describe('D: Plugin API bridge', () => {
|
||||
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible();
|
||||
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available');
|
||||
const fallback = page.locator('[data-workbench-status="no-provider"]');
|
||||
await expect(fallback).toBeVisible();
|
||||
await expect(fallback).toContainText('No viewer/editor available');
|
||||
await expect(fallback).not.toContainText('generic-text');
|
||||
});
|
||||
|
||||
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
||||
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js';
|
||||
|
||||
test.describe('E: Plugin Manager layout', () => {
|
||||
let consoleCollector;
|
||||
@@ -88,6 +88,56 @@ test.describe('E: Plugin Manager layout', () => {
|
||||
await expect(risk).toContainText('elevated permissions');
|
||||
});
|
||||
|
||||
test('plugin manager filters by state, declared permissions, capabilities, settings and source', async ({ page }) => {
|
||||
await setPluginStatus(page, 'verstak.todo', 'disabled', false);
|
||||
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(1, 'third-party'));
|
||||
await openPluginManager(page);
|
||||
|
||||
const results = page.locator('[data-plugin-filter-results]');
|
||||
await expect(results).toContainText('Showing');
|
||||
const filterSelectStyle = await page.locator('[data-plugin-filter="status"]').evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return { appearance: style.appearance, backgroundImage: style.backgroundImage };
|
||||
});
|
||||
expect(filterSelectStyle.appearance).toBe('none');
|
||||
expect(filterSelectStyle.backgroundImage).not.toBe('none');
|
||||
|
||||
await page.locator('[data-plugin-filter="status"]').selectOption('disabled');
|
||||
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||
await page.locator('button.reload-btn').click();
|
||||
await expect(page.locator('[data-plugin-filter="status"]')).toHaveValue('disabled');
|
||||
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||
|
||||
await page.locator('[data-plugin-filter-reset]').click();
|
||||
await page.locator('[data-plugin-filter-permission="notifications.schedule"]').check();
|
||||
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||
await page.locator('[data-plugin-filter-permission="secrets.read"]').check();
|
||||
await expect(page.locator('.plugin-card')).toHaveCount(2);
|
||||
await page.locator('[data-plugin-filter-permission="secrets.read"]').uncheck();
|
||||
|
||||
await page.locator('[data-plugin-filter-reset]').click();
|
||||
await page.locator('[data-plugin-filter-capability="verstak/core/notifications/v1"]').check();
|
||||
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||
await page.locator('[data-plugin-filter-capability="secret-store"]').check();
|
||||
await expect(page.locator('.plugin-card')).toHaveCount(2);
|
||||
await page.locator('[data-plugin-filter-capability="secret-store"]').uncheck();
|
||||
|
||||
await page.locator('[data-plugin-filter-reset]').click();
|
||||
await page.locator('[data-plugin-filter="settings"]').selectOption('with');
|
||||
await expect(page.locator('.plugin-card').filter({ hasText: 'Platform Test' })).toBeVisible();
|
||||
await expect(page.locator('.plugin-card').filter({ hasText: 'Default Editor' })).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-plugin-filter="source"]').selectOption('third-party');
|
||||
await expect(page.locator('.plugin-card')).toHaveCount(0);
|
||||
await expect(page.locator('[data-plugin-filter-empty]')).toBeVisible();
|
||||
|
||||
await page.locator('[data-plugin-filter-reset]').click();
|
||||
await expect(page.locator('.plugin-card')).toHaveCount(13);
|
||||
});
|
||||
|
||||
test('workspace selection keeps exactly one active node', async ({ page }) => {
|
||||
const selected = page.locator('.wt-node.selected .wt-label');
|
||||
await expect(selected).toHaveCount(1);
|
||||
@@ -118,15 +168,15 @@ test.describe('E: Plugin Manager layout', () => {
|
||||
});
|
||||
|
||||
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
|
||||
await page.locator('button[title="New workspace"]').click();
|
||||
await page.locator('button[title="New Deal"]').click();
|
||||
const modal = page.locator('[data-workspace-create-modal]');
|
||||
await modal.locator('[data-workspace-name]').fill('ClientA');
|
||||
await modal.getByRole('button', { name: 'Create workspace' }).click();
|
||||
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||
|
||||
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
|
||||
|
||||
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
|
||||
await client.locator('button[title="Rename workspace"]').click();
|
||||
await client.locator('button[title="Rename Deal"]').click();
|
||||
await page.locator('.wt-rename').fill('ClientB');
|
||||
await page.locator('button[title="Save rename"]').click();
|
||||
|
||||
@@ -134,7 +184,7 @@ test.describe('E: Plugin Manager layout', () => {
|
||||
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
|
||||
|
||||
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
|
||||
await renamed.locator('button[title="Trash workspace"]').click();
|
||||
await renamed.locator('button[title="Move Deal to trash"]').click();
|
||||
|
||||
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -248,10 +248,10 @@ test.describe('UX Overview workspace flow', () => {
|
||||
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
|
||||
const attention = overview.locator('[data-overview-section="attention"]');
|
||||
await expect(attention).toContainText('Possible journal entry');
|
||||
await expect(attention).toContainText('Workspace: Project · 10 min · 2 activities');
|
||||
await expect(attention).toContainText('Deal: Project · 10 min · 2 activities');
|
||||
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click();
|
||||
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Workspace: Project');
|
||||
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: Project');
|
||||
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
|
||||
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||
|
||||
@@ -295,4 +295,28 @@ test.describe('UX Overview workspace flow', () => {
|
||||
await expect(recent).toContainText('Added journal entry "Write project summary"');
|
||||
await expect(recent).not.toContainText('Changed file "draft.md"');
|
||||
});
|
||||
|
||||
test('Overview localizes activity labels without exposing internal event names', async ({ page }) => {
|
||||
await page.evaluate(async () => {
|
||||
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
||||
'events:workspace:Project': [{
|
||||
activityId: 'overview-russian-note',
|
||||
occurredAt: '2026-06-30T08:25:00.000Z',
|
||||
type: 'note.saved',
|
||||
title: 'Локализация',
|
||||
summary: 'Project/Notes/Localization.md',
|
||||
workspaceRootPath: 'Project',
|
||||
}],
|
||||
});
|
||||
});
|
||||
await page.locator('[data-settings-menu-button]').click();
|
||||
await page.locator('[data-settings-language="ru"]').click();
|
||||
|
||||
const overview = page.locator('[data-overview-root]');
|
||||
await overview.locator('[data-overview-action="refresh"]').click();
|
||||
const recent = overview.locator('[data-overview-section="recent"]');
|
||||
await expect(recent).toContainText('Изменена заметка «Локализация»');
|
||||
await expect(recent).not.toContainText('note.saved');
|
||||
await expect(recent).not.toContainText('Edited note');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export default {
|
||||
'pluginView.select': 'Select a plugin view from the sidebar',
|
||||
'pluginView.selectHint': 'Plugin views will appear here',
|
||||
'common.retry': '⟳ Retry',
|
||||
'common.save': 'Save',
|
||||
'common.source': 'Source',
|
||||
'common.status': 'Status',
|
||||
'common.capability': 'Capability',
|
||||
@@ -54,6 +55,24 @@ export default {
|
||||
'pluginManager.health': 'Plugin Health',
|
||||
'pluginManager.permissionRisk': 'Permission Risk',
|
||||
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
|
||||
'pluginManager.filters': 'Filters',
|
||||
'pluginManager.filterResults': 'Showing {visible} of {total} plugins',
|
||||
'pluginManager.filterReset': 'Reset filters',
|
||||
'pluginManager.filterState': 'State',
|
||||
'pluginManager.filterAll': 'All',
|
||||
'pluginManager.filterEnabled': 'Enabled',
|
||||
'pluginManager.filterDisabled': 'Disabled',
|
||||
'pluginManager.filterFailed': 'Problem',
|
||||
'pluginManager.filterDegraded': 'Degraded',
|
||||
'pluginManager.filterPermissions': 'Permissions',
|
||||
'pluginManager.filterCapabilities': 'Capabilities',
|
||||
'pluginManager.filterSettings': 'Settings',
|
||||
'pluginManager.filterWithSettings': 'With settings',
|
||||
'pluginManager.filterWithoutSettings': 'Without settings',
|
||||
'pluginManager.filterSource': 'Source',
|
||||
'pluginManager.filterOfficial': 'Official',
|
||||
'pluginManager.filterThirdParty': 'Third-party or local',
|
||||
'pluginManager.filterEmpty': 'No plugins match the selected filters.',
|
||||
'pluginManager.none': 'No plugins found',
|
||||
'pluginManager.scannedDirs': 'Plugin directories scanned:',
|
||||
'pluginManager.userPlugins': 'user plugins',
|
||||
@@ -236,6 +255,42 @@ export default {
|
||||
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
|
||||
'overview.event.workspaceOpened': 'Opened Deal',
|
||||
'overview.event.activity': 'Deal activity',
|
||||
'overview.event.noteEdited': 'Edited note "{name}"',
|
||||
'overview.event.noteOpened': 'Opened note "{name}"',
|
||||
'overview.event.noteCreated': 'Created note "{name}"',
|
||||
'overview.event.fileOpened': 'Opened file "{name}"',
|
||||
'overview.event.fileChanged': 'Changed file "{name}"',
|
||||
'overview.event.fileCreated': 'Created file "{name}"',
|
||||
'overview.event.fileRemoved': 'Removed file "{name}"',
|
||||
'overview.event.capturePage': 'Captured page "{name}"',
|
||||
'overview.event.captureSelection': 'Captured selection "{name}"',
|
||||
'overview.event.captureLink': 'Captured link "{name}"',
|
||||
'overview.event.captureFile': 'Captured file "{name}"',
|
||||
'overview.event.captureConverted': 'Converted capture "{name}"',
|
||||
'overview.event.capture': 'Captured {kind} "{title}"',
|
||||
'overview.event.journalAdded': 'Added journal entry "{name}"',
|
||||
'overview.event.workSessionDetected': 'Work session detected',
|
||||
'overview.event.todo': 'Todo "{title}"',
|
||||
'overview.event.reviewCapture': 'Review capture "{title}"',
|
||||
'overview.event.continueJournal': 'Continue journal entry "{title}"',
|
||||
'overview.event.possibleJournalEntry': 'Possible journal entry',
|
||||
'overview.captureKind.page': 'page',
|
||||
'overview.captureKind.selection': 'selection',
|
||||
'overview.captureKind.link': 'link',
|
||||
'overview.captureKind.file': 'file',
|
||||
'overview.captureKind.item': 'item',
|
||||
'overview.todo.reminderDue': 'Reminder due',
|
||||
'overview.todo.overdue': 'Overdue',
|
||||
'overview.todo.dueSoon': 'Due soon',
|
||||
'overview.todo.due': 'Due {date}',
|
||||
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
|
||||
'overview.minutes': '{count} min',
|
||||
'overview.browserCapture': 'Browser capture',
|
||||
'overview.untitledTodo': 'Untitled todo',
|
||||
'overview.untitledCapture': 'Untitled capture',
|
||||
'overview.untitledJournal': 'Journal entry',
|
||||
'overview.unknownDeal': 'Unknown Deal',
|
||||
'overview.item': 'item',
|
||||
'overview.overviewNote': 'Deal overview note',
|
||||
'overview.recentFilter': 'Recent changes filter',
|
||||
'overview.loadingRecent': 'Loading recent changes...',
|
||||
|
||||
@@ -35,9 +35,10 @@ export default {
|
||||
'pluginView.select': 'Выберите представление плагина на боковой панели',
|
||||
'pluginView.selectHint': 'Представления плагинов появятся здесь',
|
||||
'common.retry': '⟳ Повторить',
|
||||
'common.save': 'Сохранить',
|
||||
'common.source': 'Источник',
|
||||
'common.status': 'Состояние',
|
||||
'common.capability': 'Capability',
|
||||
'common.capability': 'Возможность',
|
||||
'common.provider': 'Поставщик',
|
||||
'status.loaded': 'загружено',
|
||||
'status.degraded': 'ограничено',
|
||||
@@ -48,12 +49,30 @@ export default {
|
||||
'pluginManager.reload': '⟳ Обновить',
|
||||
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
|
||||
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
|
||||
'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}',
|
||||
'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}',
|
||||
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
|
||||
'pluginManager.scanSummary': 'Сводка проверки плагинов',
|
||||
'pluginManager.health': 'Состояние плагинов',
|
||||
'pluginManager.permissionRisk': 'Риск разрешений',
|
||||
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
|
||||
'pluginManager.filters': 'Фильтры',
|
||||
'pluginManager.filterResults': 'Показано плагинов: {visible} из {total}',
|
||||
'pluginManager.filterReset': 'Сбросить фильтры',
|
||||
'pluginManager.filterState': 'Состояние',
|
||||
'pluginManager.filterAll': 'Все',
|
||||
'pluginManager.filterEnabled': 'Включённые',
|
||||
'pluginManager.filterDisabled': 'Выключенные',
|
||||
'pluginManager.filterFailed': 'С проблемами',
|
||||
'pluginManager.filterDegraded': 'Ограниченные',
|
||||
'pluginManager.filterPermissions': 'Разрешения',
|
||||
'pluginManager.filterCapabilities': 'Возможности',
|
||||
'pluginManager.filterSettings': 'Настройки',
|
||||
'pluginManager.filterWithSettings': 'С настройками',
|
||||
'pluginManager.filterWithoutSettings': 'Без настроек',
|
||||
'pluginManager.filterSource': 'Источник',
|
||||
'pluginManager.filterOfficial': 'Официальные',
|
||||
'pluginManager.filterThirdParty': 'Сторонние или локальные',
|
||||
'pluginManager.filterEmpty': 'Нет плагинов, соответствующих выбранным фильтрам.',
|
||||
'pluginManager.none': 'Плагины не найдены',
|
||||
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
|
||||
'pluginManager.userPlugins': 'пользовательские плагины',
|
||||
@@ -62,7 +81,7 @@ export default {
|
||||
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
|
||||
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
|
||||
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
|
||||
'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})',
|
||||
'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})',
|
||||
'pluginManager.settingsError': 'Ошибка настроек',
|
||||
'pluginManager.pluginSettings': 'Настройки плагина',
|
||||
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
|
||||
@@ -75,7 +94,7 @@ export default {
|
||||
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
|
||||
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
|
||||
'pluginCard.count.workspace': 'инструментов Дела: {count}',
|
||||
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.',
|
||||
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные возможности недоступны.',
|
||||
'pluginCard.name': 'Название',
|
||||
'pluginCard.contributions': 'Элементы интерфейса',
|
||||
'pluginCard.technicalDetails': 'Технические сведения',
|
||||
@@ -83,9 +102,9 @@ export default {
|
||||
'pluginCard.root': 'Корневой каталог',
|
||||
'pluginCard.provides': 'Предоставляет',
|
||||
'pluginCard.requires': 'Требует',
|
||||
'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities',
|
||||
'pluginCard.missingRequired': 'Отсутствуют обязательные возможности',
|
||||
'pluginCard.optionalRequires': 'Необязательные зависимости',
|
||||
'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме',
|
||||
'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме',
|
||||
'pluginCard.permissions': 'Разрешения',
|
||||
'pluginCard.enabling': '⟳ Включение...',
|
||||
'pluginCard.enable': '▶ Включить',
|
||||
@@ -236,6 +255,42 @@ export default {
|
||||
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
|
||||
'overview.event.workspaceOpened': 'Открыто Дело',
|
||||
'overview.event.activity': 'Активность Дела',
|
||||
'overview.event.noteEdited': 'Изменена заметка «{name}»',
|
||||
'overview.event.noteOpened': 'Открыта заметка «{name}»',
|
||||
'overview.event.noteCreated': 'Создана заметка «{name}»',
|
||||
'overview.event.fileOpened': 'Открыт файл «{name}»',
|
||||
'overview.event.fileChanged': 'Изменён файл «{name}»',
|
||||
'overview.event.fileCreated': 'Создан файл «{name}»',
|
||||
'overview.event.fileRemoved': 'Удалён файл «{name}»',
|
||||
'overview.event.capturePage': 'Сохранена страница «{name}»',
|
||||
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
|
||||
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
|
||||
'overview.event.captureFile': 'Сохранён файл «{name}»',
|
||||
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
|
||||
'overview.event.capture': 'Сохранено: {kind} «{title}»',
|
||||
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
|
||||
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
|
||||
'overview.event.todo': 'Задача «{title}»',
|
||||
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
|
||||
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
|
||||
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
|
||||
'overview.captureKind.page': 'страница',
|
||||
'overview.captureKind.selection': 'выделение',
|
||||
'overview.captureKind.link': 'ссылка',
|
||||
'overview.captureKind.file': 'файл',
|
||||
'overview.captureKind.item': 'элемент',
|
||||
'overview.todo.reminderDue': 'Пора напомнить',
|
||||
'overview.todo.overdue': 'Просрочено',
|
||||
'overview.todo.dueSoon': 'Скоро срок',
|
||||
'overview.todo.due': 'Срок: {date}',
|
||||
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
|
||||
'overview.minutes': '{count} мин.',
|
||||
'overview.browserCapture': 'Сохранённое из браузера',
|
||||
'overview.untitledTodo': 'Задача без названия',
|
||||
'overview.untitledCapture': 'Сохранённое без названия',
|
||||
'overview.untitledJournal': 'Запись журнала',
|
||||
'overview.unknownDeal': 'Неизвестное Дело',
|
||||
'overview.item': 'элемент',
|
||||
'overview.overviewNote': 'Обзорная заметка Дела',
|
||||
'overview.recentFilter': 'Фильтр недавних изменений',
|
||||
'overview.loadingRecent': 'Загрузка недавних изменений...',
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
let reloading = false;
|
||||
let toastMessage = '';
|
||||
let toastType = 'success'; // 'success' | 'error' | 'info'
|
||||
let statusFilter = 'all';
|
||||
let permissionFilters = [];
|
||||
let capabilityFilters = [];
|
||||
let settingsFilter = 'all';
|
||||
let sourceFilter = 'all';
|
||||
|
||||
export let activeSettingsPluginId = '';
|
||||
export let activeSettingsPanelId = '';
|
||||
@@ -256,6 +261,11 @@
|
||||
$: totalPerms = permissions.length;
|
||||
$: statusSummary = computeStatusSummary(plugins);
|
||||
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions);
|
||||
$: filterPermissions = collectManifestValues(plugins, 'permissions');
|
||||
$: filterCapabilities = collectCapabilities(plugins);
|
||||
$: hasReliableSourceMetadata = plugins.some((plugin) => sourceGroup(plugin) !== '');
|
||||
$: visiblePlugins = filterPlugins(plugins, statusFilter, permissionFilters, capabilityFilters, settingsFilter, sourceFilter, contributions);
|
||||
$: filtersActive = statusFilter !== 'all' || permissionFilters.length > 0 || capabilityFilters.length > 0 || settingsFilter !== 'all' || sourceFilter !== 'all';
|
||||
|
||||
function computeStatusSummary(pluginRows) {
|
||||
return pluginRows.reduce((summary, plugin) => {
|
||||
@@ -274,6 +284,69 @@
|
||||
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length;
|
||||
}
|
||||
|
||||
function collectManifestValues(pluginRows, key) {
|
||||
return Array.from(new Set((pluginRows || []).flatMap((plugin) => plugin?.manifest?.[key] || [])))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function collectCapabilities(pluginRows) {
|
||||
return Array.from(new Set((pluginRows || []).flatMap((plugin) => [
|
||||
...(plugin?.manifest?.provides || []),
|
||||
...(plugin?.manifest?.requires || []),
|
||||
...(plugin?.manifest?.optionalRequires || []),
|
||||
]))).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function pluginStatusGroup(plugin) {
|
||||
const status = plugin?.status || '';
|
||||
if (status === 'failed' || status === 'incompatible' || status === 'missing-required-capability') return 'failed';
|
||||
if (status === 'degraded') return 'degraded';
|
||||
if (status === 'disabled' || plugin?.enabled === false) return 'disabled';
|
||||
return 'enabled';
|
||||
}
|
||||
|
||||
function sourceGroup(plugin) {
|
||||
const source = plugin?.manifest?.source;
|
||||
if (source === 'official') return 'official';
|
||||
if (source === 'local' || source === 'third-party') return 'third-party';
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasSettings(plugin, activeContributions) {
|
||||
const pluginId = plugin?.manifest?.id;
|
||||
return Boolean(pluginId && (activeContributions.settingsPanels || []).some((panel) => panel.pluginId === pluginId));
|
||||
}
|
||||
|
||||
function includesAny(values, expected) {
|
||||
return expected.length === 0 || expected.some((value) => values.includes(value));
|
||||
}
|
||||
|
||||
function filterPlugins(pluginRows, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
|
||||
return pluginRows.filter((plugin) => matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions));
|
||||
}
|
||||
|
||||
function matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
|
||||
if (activeStatusFilter !== 'all' && pluginStatusGroup(plugin) !== activeStatusFilter) return false;
|
||||
if (!includesAny(plugin?.manifest?.permissions || [], activePermissionFilters)) return false;
|
||||
const declaredCapabilities = [
|
||||
...(plugin?.manifest?.provides || []),
|
||||
...(plugin?.manifest?.requires || []),
|
||||
...(plugin?.manifest?.optionalRequires || []),
|
||||
];
|
||||
if (!includesAny(declaredCapabilities, activeCapabilityFilters)) return false;
|
||||
if (activeSettingsFilter === 'with' && !hasSettings(plugin, activeContributions)) return false;
|
||||
if (activeSettingsFilter === 'without' && hasSettings(plugin, activeContributions)) return false;
|
||||
return activeSourceFilter === 'all' || sourceGroup(plugin) === activeSourceFilter;
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
statusFilter = 'all';
|
||||
permissionFilters = [];
|
||||
capabilityFilters = [];
|
||||
settingsFilter = 'all';
|
||||
sourceFilter = 'all';
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
settingsPanel = null;
|
||||
settingsPluginId = '';
|
||||
@@ -345,6 +418,70 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="plugin-filters" aria-label={tr('pluginManager.filters')}>
|
||||
<div class="filter-header">
|
||||
<div>
|
||||
<h3>{tr('pluginManager.filters')}</h3>
|
||||
<p data-plugin-filter-results>{tr('pluginManager.filterResults', { visible: visiblePlugins.length, total: totalPlugins })}</p>
|
||||
</div>
|
||||
<button class="filter-reset" data-plugin-filter-reset type="button" on:click={resetFilters} disabled={!filtersActive}>{tr('pluginManager.filterReset')}</button>
|
||||
</div>
|
||||
<div class="filter-grid">
|
||||
<label class="filter-select-label">
|
||||
<span>{tr('pluginManager.filterState')}</span>
|
||||
<select class="filter-select" data-plugin-filter="status" bind:value={statusFilter}>
|
||||
<option value="all">{tr('pluginManager.filterAll')}</option>
|
||||
<option value="enabled">{tr('pluginManager.filterEnabled')}</option>
|
||||
<option value="disabled">{tr('pluginManager.filterDisabled')}</option>
|
||||
<option value="failed">{tr('pluginManager.filterFailed')}</option>
|
||||
<option value="degraded">{tr('pluginManager.filterDegraded')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-select-label">
|
||||
<span>{tr('pluginManager.filterSettings')}</span>
|
||||
<select class="filter-select" data-plugin-filter="settings" bind:value={settingsFilter}>
|
||||
<option value="all">{tr('pluginManager.filterAll')}</option>
|
||||
<option value="with">{tr('pluginManager.filterWithSettings')}</option>
|
||||
<option value="without">{tr('pluginManager.filterWithoutSettings')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if hasReliableSourceMetadata}
|
||||
<label class="filter-select-label">
|
||||
<span>{tr('pluginManager.filterSource')}</span>
|
||||
<select class="filter-select" data-plugin-filter="source" bind:value={sourceFilter}>
|
||||
<option value="all">{tr('pluginManager.filterAll')}</option>
|
||||
<option value="official">{tr('pluginManager.filterOfficial')}</option>
|
||||
<option value="third-party">{tr('pluginManager.filterThirdParty')}</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if filterPermissions.length > 0}
|
||||
<fieldset class="filter-options">
|
||||
<legend>{tr('pluginManager.filterPermissions')}</legend>
|
||||
<div class="filter-option-list">
|
||||
{#each filterPermissions as permission}
|
||||
<label><input data-plugin-filter-permission={permission} type="checkbox" bind:group={permissionFilters} value={permission} /> <code>{permission}</code></label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
{#if filterCapabilities.length > 0}
|
||||
<fieldset class="filter-options">
|
||||
<legend>{tr('pluginManager.filterCapabilities')}</legend>
|
||||
<div class="filter-option-list">
|
||||
{#each filterCapabilities as capability}
|
||||
<label><input data-plugin-filter-capability={capability} type="checkbox" bind:group={capabilityFilters} value={capability} /> <code>{capability}</code></label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if plugins.length === 0 && missingInstalled.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-icon">
|
||||
@@ -360,9 +497,13 @@
|
||||
</ul>
|
||||
<p class="hint">{tr('pluginManager.installHint')}</p>
|
||||
</div>
|
||||
{:else if visiblePlugins.length === 0}
|
||||
<div class="empty filter-empty" data-plugin-filter-empty>
|
||||
<p>{tr('pluginManager.filterEmpty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="plugin-list">
|
||||
{#each plugins as p}
|
||||
{#each visiblePlugins as p}
|
||||
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -577,6 +718,105 @@
|
||||
color: #f4f7fb;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.plugin-filters {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 8px;
|
||||
background: #121a2c;
|
||||
}
|
||||
.filter-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.filter-header h3 {
|
||||
margin: 0;
|
||||
color: #e0e0f0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.filter-header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #a0a0b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.filter-reset {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid #533483;
|
||||
border-radius: 5px;
|
||||
background: #16213e;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.filter-reset:hover:not(:disabled) { background: #0f3460; }
|
||||
.filter-reset:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.filter-select-label {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
min-width: 10rem;
|
||||
color: #a0a0b8;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-select {
|
||||
min-height: 2rem;
|
||||
padding: 0.3rem 1.9rem 0.3rem 0.55rem;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 5px;
|
||||
background: #16213e;
|
||||
background-image: linear-gradient(45deg, transparent 50%, #a0a0b8 50%), linear-gradient(135deg, #a0a0b8 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
color: #e0e0e0;
|
||||
font: inherit;
|
||||
appearance: none;
|
||||
}
|
||||
.filter-select option { background: #16213e; color: #e0e0e0; }
|
||||
.filter-select:focus { outline: 2px solid #533483; outline-offset: 1px; }
|
||||
.filter-options {
|
||||
min-width: 0;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.55rem 0.65rem 0.65rem;
|
||||
border: 1px solid rgba(15, 52, 96, 0.85);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.filter-options legend {
|
||||
padding: 0 0.25rem;
|
||||
color: #a0a0b8;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-option-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.75rem;
|
||||
}
|
||||
.filter-option-list label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
min-width: 0;
|
||||
color: #c6c6d8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.filter-option-list input { accent-color: #6c4fa3; }
|
||||
.filter-option-list code {
|
||||
max-width: min(31rem, 72vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-empty { margin-bottom: 1.5rem; }
|
||||
.empty {
|
||||
padding: 2rem; text-align: center; color: #a0a0b8;
|
||||
background: #16213e; border-radius: 8px; border: 1px dashed #0f3460;
|
||||
@@ -585,7 +825,6 @@
|
||||
.hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; }
|
||||
.hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
|
||||
.hint-list li { margin: 0.25rem 0; }
|
||||
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
||||
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
|
||||
|
||||
.missing-section { margin-bottom: 1.5rem; }
|
||||
@@ -652,6 +891,19 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-reset {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-select-label {
|
||||
flex: 1 1 10rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(880px, calc(100vw - 2rem));
|
||||
height: min(680px, calc(100vh - 2rem));
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
}
|
||||
|
||||
function todoTitle(item) {
|
||||
return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo';
|
||||
return String(item?.title || item?.name || tr('overview.untitledTodo')).trim() || tr('overview.untitledTodo');
|
||||
}
|
||||
|
||||
function todoAttentionState(item) {
|
||||
@@ -203,9 +203,9 @@
|
||||
const now = Date.now();
|
||||
const dueAt = todoDateMs(item?.dueAt);
|
||||
const reminderAt = todoDateMs(item?.reminderAt);
|
||||
if (reminderAt && reminderAt <= now) return 'Reminder due';
|
||||
if (dueAt && dueAt < now) return 'Overdue';
|
||||
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon';
|
||||
if (reminderAt && reminderAt <= now) return tr('overview.todo.reminderDue');
|
||||
if (dueAt && dueAt < now) return tr('overview.todo.overdue');
|
||||
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon');
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -238,17 +238,12 @@
|
||||
return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled';
|
||||
}
|
||||
|
||||
function quoted(value) {
|
||||
const clean = String(value || '').trim();
|
||||
return clean ? `"${clean}"` : '';
|
||||
}
|
||||
|
||||
function entityName(item) {
|
||||
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
|
||||
const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary);
|
||||
const title = String(item?.title || payload.title || '').trim();
|
||||
if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path);
|
||||
return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item');
|
||||
return title || titleFromPath(path) || String(item?.summary || item?.activityId || tr('overview.item'));
|
||||
}
|
||||
|
||||
function looksLikePath(value) {
|
||||
@@ -258,11 +253,11 @@
|
||||
}
|
||||
|
||||
function captureTitle(capture) {
|
||||
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture';
|
||||
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || tr('overview.untitledCapture');
|
||||
}
|
||||
|
||||
function journalTitle(entry) {
|
||||
return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry';
|
||||
return entry?.title || entry?.summary || entry?.date || entry?.entryId || tr('overview.untitledJournal');
|
||||
}
|
||||
|
||||
function itemTimeLabel(item) {
|
||||
@@ -312,24 +307,33 @@
|
||||
function activityTitle(item) {
|
||||
const type = String(item?.type || '').toLowerCase();
|
||||
const name = entityName(item);
|
||||
if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`;
|
||||
if (type === 'note.opened') return `Opened note ${quoted(name)}`;
|
||||
if (type === 'note.created') return `Created note ${quoted(name)}`;
|
||||
if (type === 'file.opened') return `Opened file ${quoted(name)}`;
|
||||
if (type === 'file.changed') return `Changed file ${quoted(name)}`;
|
||||
if (type === 'file.created') return `Created file ${quoted(name)}`;
|
||||
if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`;
|
||||
if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`;
|
||||
if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`;
|
||||
if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`;
|
||||
if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`;
|
||||
if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`;
|
||||
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`;
|
||||
if (type === 'action.started') return 'Work session detected';
|
||||
if (type === 'note.saved' || type === 'note.edited') return tr('overview.event.noteEdited', { name });
|
||||
if (type === 'note.opened') return tr('overview.event.noteOpened', { name });
|
||||
if (type === 'note.created') return tr('overview.event.noteCreated', { name });
|
||||
if (type === 'file.opened') return tr('overview.event.fileOpened', { name });
|
||||
if (type === 'file.changed') return tr('overview.event.fileChanged', { name });
|
||||
if (type === 'file.created') return tr('overview.event.fileCreated', { name });
|
||||
if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name });
|
||||
if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name });
|
||||
if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name });
|
||||
if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name });
|
||||
if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name });
|
||||
if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name });
|
||||
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name });
|
||||
if (type === 'action.started') return tr('overview.event.workSessionDetected');
|
||||
if (type === 'workspace.opened') return tr('overview.event.workspaceOpened');
|
||||
return item?.title || item?.summary || tr('overview.event.activity');
|
||||
}
|
||||
|
||||
function captureKindLabel(capture) {
|
||||
const kind = String(capture?.kind || '').toLowerCase();
|
||||
if (kind === 'page') return tr('overview.captureKind.page');
|
||||
if (kind === 'selection') return tr('overview.captureKind.selection');
|
||||
if (kind === 'link') return tr('overview.captureKind.link');
|
||||
if (kind === 'file') return tr('overview.captureKind.file');
|
||||
return tr('overview.captureKind.item');
|
||||
}
|
||||
|
||||
function actionForCategory(category) {
|
||||
if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') };
|
||||
if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') };
|
||||
@@ -348,7 +352,7 @@
|
||||
id: item.activityId || `${item.type}:${timeValue(item)}`,
|
||||
category,
|
||||
title: activityTitle(item),
|
||||
meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`,
|
||||
meta: itemTimeLabel(item),
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
actionKind: action.kind,
|
||||
@@ -358,8 +362,8 @@
|
||||
const captureItems = captureRows.map(item => ({
|
||||
id: item.captureId || `capture:${timeValue(item)}`,
|
||||
category: 'captures',
|
||||
title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`,
|
||||
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`,
|
||||
title: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }),
|
||||
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`,
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
actionKind: 'browser-inbox',
|
||||
@@ -368,8 +372,8 @@
|
||||
const journalItems = journalRows.map(item => ({
|
||||
id: item.entryId || `journal:${timeValue(item)}`,
|
||||
category: 'journal',
|
||||
title: `Added journal entry ${quoted(journalTitle(item))}`,
|
||||
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`,
|
||||
title: tr('overview.event.journalAdded', { name: journalTitle(item) }),
|
||||
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`,
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
actionKind: 'journal',
|
||||
@@ -409,8 +413,8 @@
|
||||
const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({
|
||||
id: item.id || `todo:${todoTitle(item)}`,
|
||||
category: 'todo',
|
||||
title: `Todo ${quoted(todoTitle(item))}`,
|
||||
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
|
||||
title: tr('overview.event.todo', { title: todoTitle(item) }),
|
||||
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
|
||||
time: todoTimeValue(item),
|
||||
absolute: absoluteTime(todoTimeValue(item)),
|
||||
actionKind: 'todo',
|
||||
@@ -419,8 +423,8 @@
|
||||
const captureCandidates = sortByTime(captureRows).map(item => ({
|
||||
id: item.captureId || `capture:${timeValue(item)}`,
|
||||
category: 'captures',
|
||||
title: `Review capture ${quoted(captureTitle(item))}`,
|
||||
meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`,
|
||||
title: tr('overview.event.reviewCapture', { title: captureTitle(item) }),
|
||||
meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`,
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
actionKind: 'browser-inbox',
|
||||
@@ -435,7 +439,7 @@
|
||||
const journalCandidates = sortByTime(journalRows).map(item => ({
|
||||
id: item.entryId || `journal:${timeValue(item)}`,
|
||||
category: 'journal',
|
||||
title: `Continue journal entry ${quoted(journalTitle(item))}`,
|
||||
title: tr('overview.event.continueJournal', { title: journalTitle(item) }),
|
||||
meta: itemTimeLabel(item),
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
@@ -449,14 +453,18 @@
|
||||
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
|
||||
id: item.captureId || `capture:${timeValue(item)}`,
|
||||
title: captureTitle(item),
|
||||
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
|
||||
meta: `${captureKindLabel(item)} · ${itemTimeLabel(item)}`,
|
||||
actionKind: 'browser-inbox',
|
||||
actionLabel: tr('overview.reviewInbox'),
|
||||
}));
|
||||
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
|
||||
id: item.candidateId || `work-session:${timeValue(item)}`,
|
||||
title: 'Possible journal entry',
|
||||
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
|
||||
title: tr('overview.event.possibleJournalEntry'),
|
||||
meta: tr('overview.candidateMeta', {
|
||||
deal: item.workspaceRootPath || workspaceRootPath || tr('overview.unknownDeal'),
|
||||
minutes: item.estimatedMinutes || 0,
|
||||
activities: item.activityCount || (item.activityIds || []).length || 0,
|
||||
}),
|
||||
actionKind: 'journal',
|
||||
actionLabel: tr('overview.reviewCandidate'),
|
||||
toolRequest: { type: 'work-session-candidate', candidate: item },
|
||||
@@ -464,7 +472,7 @@
|
||||
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
|
||||
id: item.id || `todo:${todoTitle(item)}`,
|
||||
title: todoTitle(item),
|
||||
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
|
||||
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
|
||||
actionKind: 'todo',
|
||||
actionLabel: tr('overview.openTodos'),
|
||||
}));
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
|
||||
? 'notes-markdown'
|
||||
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
|
||||
$: resourceTitle = resourceDisplayTitle(resourcePath, requestContext);
|
||||
$: componentProps = openedResource || {};
|
||||
$: mountKey = [
|
||||
providerPluginId,
|
||||
@@ -31,6 +32,11 @@
|
||||
requestContext,
|
||||
].join(':');
|
||||
|
||||
function resourceDisplayTitle(path, context) {
|
||||
const fileName = String(path || '').split('/').filter(Boolean).pop() || String(path || '');
|
||||
return context === 'notes-markdown' ? fileName.replace(/\.(md|markdown)$/i, '') : fileName;
|
||||
}
|
||||
|
||||
function closeWorkbench() {
|
||||
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
|
||||
}
|
||||
@@ -41,7 +47,7 @@
|
||||
<div class="workbench-host vt-page">
|
||||
{#if openedResource?.status === 'no-provider'}
|
||||
<div class="workbench-header vt-page-header">
|
||||
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
||||
<span class="workbench-title vt-page-title">{resourceTitle}</span>
|
||||
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
|
||||
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
|
||||
<Icon name="x" size={18} />
|
||||
@@ -49,11 +55,13 @@
|
||||
</div>
|
||||
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
|
||||
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
|
||||
<p class="workbench-meta">{requestMode} · {requestContext}</p>
|
||||
{#if debug.isEnabled()}
|
||||
<p class="workbench-meta">{requestMode} · {requestContext}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if openedResource}
|
||||
<div class="workbench-header vt-page-header">
|
||||
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
||||
<span class="workbench-title vt-page-title">{resourceTitle}</span>
|
||||
{#if debug.isEnabled() && providerId}
|
||||
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
|
||||
{/if}
|
||||
|
||||
@@ -364,7 +364,7 @@
|
||||
if (e.key === 'Escape') cancelRename();
|
||||
}}
|
||||
/>
|
||||
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button>
|
||||
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>{tr('common.save')}</button>
|
||||
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
|
||||
{:else}
|
||||
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
|
||||
|
||||
@@ -150,7 +150,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Notes',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Workspace-scoped notes manager.',
|
||||
description: 'Deal-scoped notes manager.',
|
||||
source: 'official',
|
||||
icon: 'edit',
|
||||
provides: ['verstak/notes/v1'],
|
||||
@@ -197,7 +197,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Activity',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Workspace-scoped activity log for public plugin events.',
|
||||
description: 'Deal-scoped activity log for public plugin events.',
|
||||
source: 'official',
|
||||
icon: 'activity',
|
||||
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
|
||||
@@ -221,7 +221,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Journal',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Workspace-scoped journal with user-authored entries and optional Activity links.',
|
||||
description: 'Deal-scoped journal with user-authored entries and optional Activity links.',
|
||||
source: 'official',
|
||||
icon: 'book-open',
|
||||
provides: ['worklog', 'journal', 'report.worklog'],
|
||||
@@ -271,7 +271,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Search',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Workspace-scoped vault text search provider.',
|
||||
description: 'Deal-scoped vault text search provider.',
|
||||
source: 'official',
|
||||
icon: 'search',
|
||||
provides: ['verstak/search/v1', 'search.provider'],
|
||||
@@ -438,7 +438,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
{
|
||||
id: 'default',
|
||||
name: 'General',
|
||||
description: 'Everyday workspace with notes, files, journal, activity, and browser captures.',
|
||||
description: 'Everyday Deal with notes, files, journal, activity, and browser captures.',
|
||||
version: 2,
|
||||
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
|
||||
folders: ['Notes', 'Files'],
|
||||
@@ -456,7 +456,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
{
|
||||
id: 'writing',
|
||||
name: 'Writing',
|
||||
description: 'Focused notes, files, and journal workspace for documentation and writing.',
|
||||
description: 'Focused notes, files, and journal Deal for documentation and writing.',
|
||||
version: 1,
|
||||
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'],
|
||||
folders: ['Notes', 'Files'],
|
||||
@@ -465,7 +465,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
{
|
||||
id: 'admin',
|
||||
name: 'Admin',
|
||||
description: 'Infrastructure workspace with secrets, todos, and journal.',
|
||||
description: 'Infrastructure Deal with secrets, todos, and journal.',
|
||||
version: 1,
|
||||
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'],
|
||||
folders: ['Notes', 'Files', 'Secrets'],
|
||||
@@ -474,7 +474,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
{
|
||||
id: 'minimal',
|
||||
name: 'Minimal',
|
||||
description: 'Only notes and files for a lightweight workspace.',
|
||||
description: 'Only notes and files for a lightweight Deal.',
|
||||
version: 1,
|
||||
workspaceTools: ['verstak.notes', 'verstak.files'],
|
||||
folders: ['Notes', 'Files'],
|
||||
@@ -570,7 +570,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Todos',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Global and workspace todo tracking with due and reminder metadata.',
|
||||
description: 'Global and Deal-scoped todo tracking with due and reminder metadata.',
|
||||
source: 'official',
|
||||
icon: 'list-todo',
|
||||
provides: ['todo.list', 'todo.workspace'],
|
||||
@@ -598,7 +598,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Secrets',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Encrypted global and workspace-scoped secret manager.',
|
||||
description: 'Encrypted global and Deal-scoped secret manager.',
|
||||
source: 'official',
|
||||
icon: 'key-round',
|
||||
provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'],
|
||||
@@ -4104,7 +4104,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Notes',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Workspace-scoped notes manager.',
|
||||
description: 'Deal-scoped notes manager.',
|
||||
source: 'official',
|
||||
icon: 'edit',
|
||||
provides: ['verstak/notes/v1'],
|
||||
@@ -4151,7 +4151,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Activity',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Workspace-scoped activity log for public plugin events.',
|
||||
description: 'Deal-scoped activity log for public plugin events.',
|
||||
source: 'official',
|
||||
icon: 'activity',
|
||||
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
|
||||
@@ -4175,7 +4175,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Journal',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Workspace-scoped journal with user-authored entries and optional Activity links.',
|
||||
description: 'Deal-scoped journal with user-authored entries and optional Activity links.',
|
||||
source: 'official',
|
||||
icon: 'book-open',
|
||||
provides: ['worklog', 'journal', 'report.worklog'],
|
||||
@@ -4225,7 +4225,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
name: 'Search',
|
||||
version: '0.1.0',
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Workspace-scoped vault text search provider.',
|
||||
description: 'Deal-scoped vault text search provider.',
|
||||
source: 'official',
|
||||
icon: 'search',
|
||||
provides: ['verstak/search/v1', 'search.provider'],
|
||||
@@ -4276,8 +4276,9 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
getPluginState: function (pluginId) {
|
||||
return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null;
|
||||
},
|
||||
addSyntheticPlugins: function (count) {
|
||||
addSyntheticPlugins: function (count, source) {
|
||||
var total = Number(count || 0);
|
||||
var pluginSource = source === 'official' || source === 'local' || source === 'third-party' ? source : 'third-party';
|
||||
for (var i = 1; i <= total; i++) {
|
||||
var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0');
|
||||
pluginStates[id] = {
|
||||
@@ -4290,7 +4291,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
version: '0.0.' + i,
|
||||
apiVersion: '0.1.0',
|
||||
description: 'Synthetic plugin used by frontend layout tests.',
|
||||
source: 'test',
|
||||
source: pluginSource,
|
||||
provides: ['verstak/synthetic-layout-' + i + '/v1'],
|
||||
requires: [],
|
||||
optionalRequires: [],
|
||||
@@ -4310,7 +4311,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
||||
vaultPluginState.enabledPlugins.push(id);
|
||||
}
|
||||
if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) {
|
||||
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: 'test' });
|
||||
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: pluginSource });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/verstak/verstak-desktop
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/getlantern/systray v1.2.2
|
||||
fyne.io/systray v1.12.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
golang.org/x/net v0.35.0
|
||||
@@ -12,14 +12,7 @@ require (
|
||||
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
|
||||
@@ -31,7 +24,6 @@ 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
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/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=
|
||||
@@ -45,8 +30,6 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/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=
|
||||
@@ -55,8 +38,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.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=
|
||||
@@ -68,9 +49,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/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=
|
||||
@@ -91,7 +69,6 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/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=
|
||||
@@ -104,6 +81,5 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/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=
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -100,6 +101,8 @@ type App struct {
|
||||
browserInboxEvents map[string]bool
|
||||
browserInboxEnabled atomic.Bool
|
||||
allowQuit atomic.Bool
|
||||
trayReady atomic.Bool
|
||||
quitOnce sync.Once
|
||||
}
|
||||
|
||||
// SetNotificationService attaches the core-owned plugin notification scheduler.
|
||||
@@ -221,11 +224,23 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
cleanupNativeNotifications(ctx)
|
||||
}
|
||||
|
||||
// BeforeClose hides the primary window until the user explicitly quits from the tray.
|
||||
// SetTrayReady reports whether the native tray can safely return a hidden window.
|
||||
func (a *App) SetTrayReady(ready bool) {
|
||||
if a != nil {
|
||||
a.trayReady.Store(ready)
|
||||
}
|
||||
}
|
||||
|
||||
// BeforeClose hides the primary window only after the native tray has confirmed
|
||||
// that it can return the window. Otherwise the normal Wails close path exits.
|
||||
func (a *App) BeforeClose(ctx context.Context) bool {
|
||||
if a.allowQuit.Load() {
|
||||
return false
|
||||
}
|
||||
if !a.trayReady.Load() {
|
||||
log.Printf("[app] tray is unavailable; allowing normal window close")
|
||||
return false
|
||||
}
|
||||
hideNativeWindow(ctx)
|
||||
return true
|
||||
}
|
||||
@@ -243,10 +258,12 @@ func (a *App) Quit() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.allowQuit.Store(true)
|
||||
if a.ctx != nil {
|
||||
quitNativeApplication(a.ctx)
|
||||
}
|
||||
a.quitOnce.Do(func() {
|
||||
a.allowQuit.Store(true)
|
||||
if a.ctx != nil {
|
||||
quitNativeApplication(a.ctx)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// NativeNotificationSender delivers scheduler items through the Wails runtime.
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
|
||||
func TestBeforeCloseAllowsExitUntilTrayIsReady(t *testing.T) {
|
||||
oldHide := hideNativeWindow
|
||||
defer func() { hideNativeWindow = oldHide }()
|
||||
|
||||
@@ -327,8 +327,25 @@ func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
|
||||
hideNativeWindow = func(context.Context) { hideCalls++ }
|
||||
app := &App{}
|
||||
|
||||
if prevent := app.BeforeClose(context.Background()); prevent {
|
||||
t.Fatal("BeforeClose() = true, want false while tray is unavailable")
|
||||
}
|
||||
if hideCalls != 0 {
|
||||
t.Fatalf("hide calls = %d, want 0", hideCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeCloseHidesWindowAfterTrayIsReady(t *testing.T) {
|
||||
oldHide := hideNativeWindow
|
||||
defer func() { hideNativeWindow = oldHide }()
|
||||
|
||||
hideCalls := 0
|
||||
hideNativeWindow = func(context.Context) { hideCalls++ }
|
||||
app := &App{}
|
||||
app.SetTrayReady(true)
|
||||
|
||||
if prevent := app.BeforeClose(context.Background()); !prevent {
|
||||
t.Fatal("BeforeClose() = false, want true while tray mode is active")
|
||||
t.Fatal("BeforeClose() = false, want true while tray is ready")
|
||||
}
|
||||
if hideCalls != 1 {
|
||||
t.Fatalf("hide calls = %d, want 1", hideCalls)
|
||||
@@ -353,6 +370,22 @@ func TestTrayQuitAllowsWindowCloseAndQuitsApplication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrayQuitRequestsNativeShutdownOnlyOnce(t *testing.T) {
|
||||
oldQuit := quitNativeApplication
|
||||
defer func() { quitNativeApplication = oldQuit }()
|
||||
|
||||
quitCalls := 0
|
||||
quitNativeApplication = func(context.Context) { quitCalls++ }
|
||||
app := &App{ctx: context.Background()}
|
||||
|
||||
app.Quit()
|
||||
app.Quit()
|
||||
|
||||
if quitCalls != 1 {
|
||||
t.Fatalf("native quit calls = %d, want 1", quitCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowWindowUsesWailsContext(t *testing.T) {
|
||||
oldShow := showNativeWindow
|
||||
defer func() { showNativeWindow = oldShow }()
|
||||
|
||||
@@ -78,9 +78,10 @@ type WindowState struct {
|
||||
|
||||
// Manager provides thread-safe access to app settings.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
config *Config
|
||||
configPath string
|
||||
mu sync.RWMutex
|
||||
config *Config
|
||||
configPath string
|
||||
onLanguageChanged func(string)
|
||||
}
|
||||
|
||||
// DefaultConfigPath returns the default path for app settings.
|
||||
@@ -230,13 +231,32 @@ func (m *Manager) UpdateLanguage(language string) error {
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.config == nil {
|
||||
m.config = defaultConfig()
|
||||
}
|
||||
m.config.Language = language
|
||||
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return m.saveLocked()
|
||||
err := m.saveLocked()
|
||||
handler := m.onLanguageChanged
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handler != nil {
|
||||
handler(language)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguageChangedHandler registers the shell callback invoked after a
|
||||
// language preference has been durably updated.
|
||||
func (m *Manager) SetLanguageChangedHandler(handler func(string)) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.onLanguageChanged = handler
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// UpdateSync replaces sync settings without changing unrelated app settings.
|
||||
|
||||
@@ -3,6 +3,7 @@ package appsettings
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -74,6 +75,34 @@ func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLanguageNotifiesTheDesktopShellAfterPersisting(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
manager := NewManager(path)
|
||||
if err := manager.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var received []string
|
||||
manager.SetLanguageChangedHandler(func(language string) {
|
||||
received = append(received, language)
|
||||
})
|
||||
if err := manager.UpdateLanguage(LanguageRussian); err != nil {
|
||||
t.Fatalf("UpdateLanguage: %v", err)
|
||||
}
|
||||
if got := manager.Get().Language; got != LanguageRussian {
|
||||
t.Fatalf("persisted language = %q, want %q", got, LanguageRussian)
|
||||
}
|
||||
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
|
||||
t.Fatalf("language notifications = %#v, want [%q]", received, LanguageRussian)
|
||||
}
|
||||
if err := manager.UpdateLanguage("de"); err == nil {
|
||||
t.Fatal("UpdateLanguage(de) succeeded, want validation failure")
|
||||
}
|
||||
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
|
||||
t.Fatalf("language notification fired after rejected update: %#v", received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CorruptConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
// Package tray owns the native tray menu wiring for the desktop shell.
|
||||
package tray
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var errBackendUnavailable = errors.New("tray backend is unavailable")
|
||||
|
||||
// MenuItem exposes click events from a native tray menu item.
|
||||
type MenuItem interface {
|
||||
Clicked() <-chan struct{}
|
||||
SetTitle(title string)
|
||||
SetTooltip(tooltip string)
|
||||
}
|
||||
|
||||
// BackendCallbacks are invoked by the native tray implementation.
|
||||
// A backend keeps the platform-native secondary-click handler enabled so the
|
||||
// operating system opens the menu on a right click.
|
||||
type BackendCallbacks struct {
|
||||
Ready func()
|
||||
Exit func()
|
||||
LeftClick func()
|
||||
}
|
||||
|
||||
// Backend is the platform tray implementation.
|
||||
type Backend interface {
|
||||
Register(onReady func(), onExit func())
|
||||
SetIcon(icon []byte)
|
||||
SetTooltip(tooltip string)
|
||||
AddMenuItem(title, tooltip string) MenuItem
|
||||
Quit()
|
||||
Start(callbacks BackendCallbacks) error
|
||||
SetIcon(icon []byte) error
|
||||
SetTooltip(tooltip string) error
|
||||
AddMenuItem(title, tooltip string) (MenuItem, error)
|
||||
Stop()
|
||||
}
|
||||
|
||||
// Actions are executed from the native tray menu.
|
||||
@@ -23,11 +42,72 @@ type Actions struct {
|
||||
Quit func()
|
||||
}
|
||||
|
||||
// Labels contains the user-visible text for one tray menu locale.
|
||||
type Labels struct {
|
||||
ShowTitle string
|
||||
ShowTooltip string
|
||||
QuitTitle string
|
||||
QuitTooltip string
|
||||
}
|
||||
|
||||
func englishLabels() Labels {
|
||||
return Labels{
|
||||
ShowTitle: "Show Verstak",
|
||||
ShowTooltip: "Show the Verstak window",
|
||||
QuitTitle: "Quit",
|
||||
QuitTooltip: "Quit Verstak",
|
||||
}
|
||||
}
|
||||
|
||||
func russianLabels() Labels {
|
||||
return Labels{
|
||||
ShowTitle: "Показать Верстак",
|
||||
ShowTooltip: "Показать окно Верстака",
|
||||
QuitTitle: "Выйти",
|
||||
QuitTooltip: "Завершить Верстак",
|
||||
}
|
||||
}
|
||||
|
||||
// LabelsForPreference resolves the saved language preference for native tray UI.
|
||||
// System locale values are intentionally passed in by the shell so this package
|
||||
// stays independent of process environment and is deterministic in tests.
|
||||
func LabelsForPreference(preference string, systemLocales ...string) Labels {
|
||||
if strings.EqualFold(strings.TrimSpace(preference), "ru") {
|
||||
return russianLabels()
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(preference), "system") {
|
||||
for _, locale := range systemLocales {
|
||||
locale = strings.ToLower(strings.TrimSpace(locale))
|
||||
if locale == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(locale, "ru") {
|
||||
return russianLabels()
|
||||
}
|
||||
return englishLabels()
|
||||
}
|
||||
}
|
||||
return englishLabels()
|
||||
}
|
||||
|
||||
// Controller initializes one native tray and routes its menu actions.
|
||||
type Controller struct {
|
||||
backend Backend
|
||||
icon []byte
|
||||
start sync.Once
|
||||
|
||||
startOnce sync.Once
|
||||
readyOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
started atomic.Bool
|
||||
ready atomic.Bool
|
||||
|
||||
mu sync.RWMutex
|
||||
labels Labels
|
||||
actions Actions
|
||||
show MenuItem
|
||||
quit MenuItem
|
||||
startErr error
|
||||
readyChanged func(bool)
|
||||
}
|
||||
|
||||
// New creates a tray controller for one application process.
|
||||
@@ -35,36 +115,196 @@ func New(backend Backend, icon []byte) *Controller {
|
||||
return &Controller{
|
||||
backend: backend,
|
||||
icon: append([]byte(nil), icon...),
|
||||
labels: englishLabels(),
|
||||
}
|
||||
}
|
||||
|
||||
// Start registers the native tray without taking over the Wails event loop.
|
||||
func (c *Controller) Start(actions Actions) {
|
||||
if c == nil || c.backend == nil {
|
||||
// SetReadyChangedHandler receives readiness changes after native initialization
|
||||
// has either completed or ended. It lets the Wails close policy avoid hiding a
|
||||
// window before the tray can bring it back.
|
||||
func (c *Controller) SetReadyChangedHandler(handler func(bool)) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.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)
|
||||
c.mu.Lock()
|
||||
c.readyChanged = handler
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Ready reports whether the tray has completed icon and menu initialization.
|
||||
func (c *Controller) Ready() bool {
|
||||
return c != nil && c.ready.Load()
|
||||
}
|
||||
|
||||
// SetLabels updates the current and future native tray menu labels.
|
||||
func (c *Controller) SetLabels(labels Labels) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.labels = labels
|
||||
show, quit := c.show, c.quit
|
||||
c.mu.Unlock()
|
||||
applyLabels(show, quit, labels)
|
||||
}
|
||||
|
||||
// Start connects the tray to Wails without taking over Wails' GUI event loop.
|
||||
// Ready remains false until the backend callback and all required menu setup
|
||||
// finish successfully.
|
||||
func (c *Controller) Start(actions Actions) error {
|
||||
if c == nil || c.backend == nil {
|
||||
return errBackendUnavailable
|
||||
}
|
||||
c.startOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
c.actions = actions
|
||||
c.mu.Unlock()
|
||||
c.started.Store(true)
|
||||
log.Printf("[tray] starting native backend")
|
||||
err := c.backend.Start(BackendCallbacks{
|
||||
Ready: c.onReady,
|
||||
Exit: c.onExit,
|
||||
LeftClick: c.onLeftClick,
|
||||
})
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
c.startErr = err
|
||||
c.mu.Unlock()
|
||||
c.started.Store(false)
|
||||
c.setReady(false)
|
||||
log.Printf("[tray] backend start failed: %v; falling back to normal window close", err)
|
||||
}
|
||||
})
|
||||
c.mu.RLock()
|
||||
err := c.startErr
|
||||
c.mu.RUnlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Controller) onReady() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if !c.started.Load() {
|
||||
log.Printf("[tray] ready callback ignored after backend startup failed")
|
||||
return
|
||||
}
|
||||
c.readyOnce.Do(func() {
|
||||
log.Printf("[tray] native backend reported ready")
|
||||
if err := c.backend.SetIcon(c.icon); err != nil {
|
||||
c.fail("icon setup", err)
|
||||
return
|
||||
}
|
||||
if err := c.backend.SetTooltip("Verstak"); err != nil {
|
||||
c.fail("tooltip setup", err)
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
labels := c.labels
|
||||
actions := c.actions
|
||||
c.mu.RUnlock()
|
||||
show, err := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
|
||||
if err != nil || show == nil {
|
||||
if err == nil {
|
||||
err = errors.New("show menu item is nil")
|
||||
}
|
||||
c.fail("show menu creation", err)
|
||||
return
|
||||
}
|
||||
quit, err := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
|
||||
if err != nil || quit == nil {
|
||||
if err == nil {
|
||||
err = errors.New("quit menu item is nil")
|
||||
}
|
||||
c.fail("quit menu creation", err)
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.show, c.quit = show, quit
|
||||
labels = c.labels
|
||||
c.mu.Unlock()
|
||||
applyLabels(show, quit, labels)
|
||||
if actions.Show != nil {
|
||||
go routeClicks(show.Clicked(), func() {
|
||||
log.Printf("[tray] Show command")
|
||||
actions.Show()
|
||||
})
|
||||
}
|
||||
if actions.Quit != nil {
|
||||
go routeClicks(quit.Clicked(), func() {
|
||||
log.Printf("[tray] Quit command")
|
||||
actions.Quit()
|
||||
})
|
||||
}
|
||||
c.setReady(true)
|
||||
log.Printf("[tray] native tray is ready")
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Controller) onLeftClick() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[tray] left click")
|
||||
if !c.Ready() {
|
||||
log.Printf("[tray] left click ignored while tray is not ready")
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
show := c.actions.Show
|
||||
c.mu.RUnlock()
|
||||
if show != nil {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) onExit() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.setReady(false)
|
||||
log.Printf("[tray] native message loop ended; falling back to normal window close")
|
||||
}
|
||||
|
||||
func (c *Controller) fail(stage string, err error) {
|
||||
c.setReady(false)
|
||||
log.Printf("[tray] %s failed: %v; falling back to normal window close", stage, err)
|
||||
c.Stop()
|
||||
}
|
||||
|
||||
func (c *Controller) setReady(ready bool) {
|
||||
if c == nil || c.ready.Swap(ready) == ready {
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
handler := c.readyChanged
|
||||
c.mu.RUnlock()
|
||||
if handler != nil {
|
||||
handler(ready)
|
||||
}
|
||||
}
|
||||
|
||||
func applyLabels(show, quit MenuItem, labels Labels) {
|
||||
if show != nil {
|
||||
show.SetTitle(labels.ShowTitle)
|
||||
show.SetTooltip(labels.ShowTooltip)
|
||||
}
|
||||
if quit != nil {
|
||||
quit.SetTitle(labels.QuitTitle)
|
||||
quit.SetTooltip(labels.QuitTooltip)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop releases the native tray after Wails has begun application shutdown.
|
||||
func (c *Controller) Stop() {
|
||||
if c == nil || c.backend == nil {
|
||||
if c == nil || c.backend == nil || !c.started.Load() {
|
||||
return
|
||||
}
|
||||
c.backend.Quit()
|
||||
c.stopOnce.Do(func() {
|
||||
c.setReady(false)
|
||||
log.Printf("[tray] stopping native backend")
|
||||
c.backend.Stop()
|
||||
})
|
||||
}
|
||||
|
||||
func routeClicks(clicked <-chan struct{}, action func()) {
|
||||
|
||||
@@ -1,47 +1,77 @@
|
||||
package tray
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeMenuItem struct {
|
||||
clicked chan struct{}
|
||||
title string
|
||||
tooltip string
|
||||
}
|
||||
|
||||
func (i *fakeMenuItem) Clicked() <-chan struct{} {
|
||||
return i.clicked
|
||||
}
|
||||
func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked }
|
||||
func (i *fakeMenuItem) SetTitle(title string) { i.title = title }
|
||||
func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip }
|
||||
|
||||
type fakeBackend struct {
|
||||
icon []byte
|
||||
tooltip string
|
||||
items map[string]*fakeMenuItem
|
||||
quitCalls int
|
||||
registering bool
|
||||
callbacks BackendCallbacks
|
||||
icon []byte
|
||||
tooltip string
|
||||
items map[string]*fakeMenuItem
|
||||
startErr error
|
||||
iconErr error
|
||||
menuErr error
|
||||
starts int
|
||||
stops int
|
||||
}
|
||||
|
||||
func (b *fakeBackend) Register(onReady func(), _ func()) {
|
||||
b.registering = true
|
||||
onReady()
|
||||
func (b *fakeBackend) Start(callbacks BackendCallbacks) error {
|
||||
b.starts++
|
||||
b.callbacks = callbacks
|
||||
return b.startErr
|
||||
}
|
||||
|
||||
func (b *fakeBackend) SetIcon(icon []byte) {
|
||||
func (b *fakeBackend) SetIcon(icon []byte) error {
|
||||
b.icon = append([]byte(nil), icon...)
|
||||
return b.iconErr
|
||||
}
|
||||
|
||||
func (b *fakeBackend) SetTooltip(tooltip string) {
|
||||
func (b *fakeBackend) SetTooltip(tooltip string) error {
|
||||
b.tooltip = tooltip
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
|
||||
item := &fakeMenuItem{clicked: make(chan struct{}, 1)}
|
||||
func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
|
||||
if b.menuErr != nil {
|
||||
return nil, b.menuErr
|
||||
}
|
||||
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip}
|
||||
b.items[title] = item
|
||||
return item
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (b *fakeBackend) Quit() {
|
||||
b.quitCalls++
|
||||
func (b *fakeBackend) Stop() { b.stops++ }
|
||||
|
||||
func (b *fakeBackend) ready() {
|
||||
if b.callbacks.Ready != nil {
|
||||
b.callbacks.Ready()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBackend) exited() {
|
||||
if b.callbacks.Exit != nil {
|
||||
b.callbacks.Exit()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBackend) leftClick() {
|
||||
if b.callbacks.LeftClick != nil {
|
||||
b.callbacks.LeftClick()
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, signal <-chan struct{}) {
|
||||
@@ -53,39 +83,145 @@ func waitFor(t *testing.T, signal <-chan struct{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) {
|
||||
func TestControllerBecomesReadyOnlyAfterBackendCallback(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
showCalls := make(chan struct{}, 1)
|
||||
quitCalls := make(chan struct{}, 1)
|
||||
controller := New(backend, []byte{1, 2, 3})
|
||||
|
||||
controller.Start(Actions{
|
||||
if err := controller.Start(Actions{}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
if controller.Ready() {
|
||||
t.Fatal("tray became ready before backend callback")
|
||||
}
|
||||
|
||||
backend.ready()
|
||||
if !controller.Ready() {
|
||||
t.Fatal("tray did not become ready after backend callback")
|
||||
}
|
||||
if string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
|
||||
t.Fatalf("tray initialization = icon:%v tooltip:%q", backend.icon, backend.tooltip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRoutesLeftClickAndMenuActionsToShowAndQuit(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
showCalls := make(chan struct{}, 2)
|
||||
quitCalls := make(chan struct{}, 1)
|
||||
controller := New(backend, []byte{1})
|
||||
|
||||
if err := controller.Start(Actions{
|
||||
Show: func() { showCalls <- struct{}{} },
|
||||
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)
|
||||
}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
backend.ready()
|
||||
|
||||
backend.leftClick()
|
||||
waitFor(t, showCalls)
|
||||
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) {
|
||||
func TestControllerStopCallsBackendOnce(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
controller := New(backend, []byte{1})
|
||||
if err := controller.Start(Actions{}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
controller.Stop()
|
||||
controller.Stop()
|
||||
|
||||
if backend.quitCalls != 1 {
|
||||
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
|
||||
if backend.stops != 1 {
|
||||
t.Fatalf("backend stop calls = %d, want 1", backend.stops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerStartupAndSetupFailuresNeverBecomeReady(t *testing.T) {
|
||||
for name, backend := range map[string]*fakeBackend{
|
||||
"start": {items: make(map[string]*fakeMenuItem), startErr: errors.New("start failed")},
|
||||
"icon": {items: make(map[string]*fakeMenuItem), iconErr: errors.New("icon failed")},
|
||||
"menu": {items: make(map[string]*fakeMenuItem), menuErr: errors.New("menu failed")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
controller := New(backend, []byte{1})
|
||||
err := controller.Start(Actions{})
|
||||
if name == "start" && err == nil {
|
||||
t.Fatal("Start() error = nil, want startup failure")
|
||||
}
|
||||
if name != "start" && err != nil {
|
||||
t.Fatalf("Start() error = %v, want nil before ready callback", err)
|
||||
}
|
||||
backend.ready()
|
||||
if controller.Ready() {
|
||||
t.Fatal("failed tray became ready")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerExitRevokesReadinessAndKeepsLocalizedMenuItems(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
controller := New(backend, []byte{1})
|
||||
controller.SetLabels(LabelsForPreference("ru"))
|
||||
if err := controller.Start(Actions{}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
backend.ready()
|
||||
|
||||
show := backend.items["Показать Верстак"]
|
||||
quit := backend.items["Выйти"]
|
||||
if show == nil || quit == nil {
|
||||
t.Fatalf("Russian tray menu = %#v", backend.items)
|
||||
}
|
||||
controller.SetLabels(LabelsForPreference("en"))
|
||||
if show.title != "Show Verstak" || quit.title != "Quit" {
|
||||
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title)
|
||||
}
|
||||
backend.exited()
|
||||
if controller.Ready() {
|
||||
t.Fatal("tray stayed ready after backend exit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelsForSystemRussianLocale(t *testing.T) {
|
||||
labels := LabelsForPreference("system", "", "ru_RU.UTF-8", "en_US.UTF-8")
|
||||
if labels.ShowTitle != "Показать Верстак" || labels.QuitTitle != "Выйти" {
|
||||
t.Fatalf("system Russian labels = %#v", labels)
|
||||
}
|
||||
labels = LabelsForPreference("system", "C.UTF-8", "", "ru_RU.UTF-8")
|
||||
if labels.ShowTitle != "Show Verstak" || labels.QuitTitle != "Quit" {
|
||||
t.Fatalf("higher-priority system locale must win, got %#v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerReadyNotificationIsSafeAcrossCallbacks(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
controller := New(backend, []byte{1})
|
||||
var changes []bool
|
||||
var mu sync.Mutex
|
||||
controller.SetReadyChangedHandler(func(ready bool) {
|
||||
mu.Lock()
|
||||
changes = append(changes, ready)
|
||||
mu.Unlock()
|
||||
})
|
||||
if err := controller.Start(Actions{}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
backend.ready()
|
||||
backend.exited()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(changes) != 2 || !changes[0] || changes[1] {
|
||||
t.Fatalf("ready changes = %#v, want [true false]", changes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package tray
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
const iconPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACT1BMVEUAAACOjo7///8RERHY2NghISEDAwOjo6MODg7GxsYQEBDIyMgPDw/Hx8cKCgq9vb12dnaHh4fFxcV1dXWjo6OoqKinp6eNjY25ubnr6+v29vb29vb29vbx8fHV1dXm5ub9/f3z8/OUlJQAAADu7u74+Pi5ubkAAADu7u74+Pi6urq6urrs7Oz39/ezs7PZ2dn5+fn8/Pzq6uobGxtdXV3U1NTl5eXl5eXl5eXm5ubd3d2urq7////+/v7//v7//v/s7OzLy8vLy8ru7u708/POzc3Kysrd3d39/f3Z2dnU1NT7+/u0tLQpKSkoKCcnJycsLCy+vr63t7crKyskJCRzc3NQUFAlJSUoKChvb28gICAdHR0cHBzDwsJ6enoaGhkZGRltbW3MzMwqKioeHh6pqam+vb0iIiJGRkYbGxseHR0aGhpubm6cnJw6Ojrg4ODExMQmJiYeHh36+vppaWhxcXH8/PwvLy/a2tmKiorl5eU+Pj6zs7PQ0NA1NTXR0dFSUlIbGhq/v79BQUHn5+fW1tYuLi48PDyysrItLS02NjaNjY17e3v///7b29syMjJ/f38hISEqKSlqampaWlq9vb3f398cGxs9PT1KSko3NzeioqJRUVFJSUnk5OQaGRljY2OGhobp6ek/Pz+dnZ3IyMgoKSjHx8ZDQ0PHx8fx8fHw8PBVVVX39/cdHh2RkZHz8/NPT0+Ojo7Dw8PPz8/29vZUVFTCwsL19fX4+PhYWFgWFhYXFxfAwMCqqqqFhYWHhoaEhITe3t6MjIzi4uIwLXXJAAAAPHRSTlMAAAAAAAAAAAAAAAAAAAAAAAAABRIWFQoUis3R0K82b/22DQGZ2x0Cm9wfHo/UGULi+IIEAzZudHNzUA0lLC8OAAAAAWJLR0QCZgt8ZAAAAAd0SU1FB+oHDRIyN3EuEOkAAAHzSURBVDjLvdPXXxNBEAdwPHvvFewde8WyG3NGT41KlFxMwoyKguLeCCZRUCzRgIXYUexEjR17713/MPfCB+Vy8ZXfw+3tzvdlbueyslolbfr26z8gQwYqbVP1doMGD8nOsSU7Z+gwpb2sd1CGj2CZM3JURwk6jR4j37l5wDlvejrkssjBxo7rLEGX8RPk1qmqi1UXW7JU05bx5Ss0VXOvZBNzu/4Fq1bne9asZQVej+7jrnX+gB6UYNI/UFAIqK9nGzZC0Sa2uRihZAuzgK2lAB7VtU0YtJ2VIWD5DisI+QG87lAYIhTcuYsiVFFpBVo+4O49VQKAKvbuE1C6XzbWEhwoBowePBSDEvJV+0HUHE4DR44iHquN43G9yHdCROgkP2UFp8+gqDkLdcFzdL6ewH9Bfi8LuBjFS5ev0NVrAdEQRZFwpoPr5QiB8I2b1WHw1oFIMkcauHVbGAB4565sF+De/dS9tATOB8IwMO5uNEGs4aENPEpIQI+fhHR4CvjMrFvB8xcxA8RL9kqHCMZf28Gbt2RAoJG9k4Dq39vBh4+f6HPCxb4UElFZqm4FlV+/ff9Ry9nPqmTy128L6GYCR/OwNY9eE8jtLkEPZTL/z9BOmdrTHHtl2nSeMTNmzuplgt6z58zNm2dL3vwFC/u0yo/7B9C02RGfGBOeAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA3LTEzVDE0OjIyOjMzKzAwOjAwJHU36gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNy0xM1QxNDoyMjozMyswMDowMFUoj1YAAAAASUVORK5CYII="
|
||||
|
||||
// DefaultIcon returns the compact source-controlled Verstak tray icon.
|
||||
func DefaultIcon() []byte {
|
||||
icon, err := base64.StdEncoding.DecodeString(iconPNGBase64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return icon
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build linux
|
||||
|
||||
package tray
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed verstak.png
|
||||
var defaultIcon []byte
|
||||
|
||||
// DefaultIcon returns the Linux PNG tray icon embedded in the binary.
|
||||
func DefaultIcon() []byte {
|
||||
return append([]byte(nil), defaultIcon...)
|
||||
}
|
||||
|
||||
// IconFileExtension is used when the backend materializes the embedded icon.
|
||||
func IconFileExtension() string { return ".png" }
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build linux
|
||||
|
||||
package tray
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultIconUsesPlatformResource(t *testing.T) {
|
||||
icon := DefaultIcon()
|
||||
if len(icon) == 0 {
|
||||
t.Fatal("DefaultIcon() returned no data")
|
||||
}
|
||||
if IconFileExtension() != ".png" {
|
||||
t.Fatalf("IconFileExtension() = %q, want .png on Linux", IconFileExtension())
|
||||
}
|
||||
if string(icon[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||
t.Fatal("DefaultIcon() is not PNG data on Linux")
|
||||
}
|
||||
assertVerstakBrand(t, icon)
|
||||
}
|
||||
|
||||
func assertVerstakBrand(t *testing.T, icon []byte) {
|
||||
t.Helper()
|
||||
image, _, err := image.Decode(bytes.NewReader(icon))
|
||||
if err != nil {
|
||||
t.Fatalf("decode tray icon: %v", err)
|
||||
}
|
||||
|
||||
want := color.NRGBA{R: 0x1c, G: 0x2f, B: 0x4a, A: 0xff}
|
||||
for y := image.Bounds().Min.Y; y < image.Bounds().Max.Y; y++ {
|
||||
for x := image.Bounds().Min.X; x < image.Bounds().Max.X; x++ {
|
||||
if color.NRGBAModel.Convert(image.At(x, y)) == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("tray icon does not contain the Verstak navy brand color")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package tray
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed verstak.ico
|
||||
var defaultIcon []byte
|
||||
|
||||
// DefaultIcon returns the multi-resolution Windows ICO embedded in the binary.
|
||||
func DefaultIcon() []byte {
|
||||
return append([]byte(nil), defaultIcon...)
|
||||
}
|
||||
|
||||
// IconFileExtension is used when the backend materializes the embedded icon.
|
||||
func IconFileExtension() string { return ".ico" }
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build windows
|
||||
|
||||
package tray
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultIconIsMultiResolutionICO(t *testing.T) {
|
||||
icon := DefaultIcon()
|
||||
if IconFileExtension() != ".ico" || len(icon) < 6 {
|
||||
t.Fatalf("Windows tray icon is not ICO data")
|
||||
}
|
||||
if binary.LittleEndian.Uint16(icon[0:2]) != 0 || binary.LittleEndian.Uint16(icon[2:4]) != 1 {
|
||||
t.Fatal("Windows tray icon has an invalid ICO header")
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint16(icon[4:6]))
|
||||
if count < 6 {
|
||||
t.Fatalf("ICO image count = %d, want at least 6", count)
|
||||
}
|
||||
want := map[int]bool{16: false, 20: false, 24: false, 32: false, 48: false, 256: false}
|
||||
for offset := 6; offset+16 <= len(icon) && offset < 6+count*16; offset += 16 {
|
||||
size := int(icon[offset])
|
||||
if size == 0 {
|
||||
size = 256
|
||||
}
|
||||
if _, ok := want[size]; ok {
|
||||
want[size] = true
|
||||
}
|
||||
}
|
||||
for size, found := range want {
|
||||
if !found {
|
||||
t.Errorf("ICO is missing %dx%d image", size, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,141 @@
|
||||
package tray
|
||||
|
||||
import "github.com/getlantern/systray"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
type systrayBackend struct{}
|
||||
"fyne.io/systray"
|
||||
)
|
||||
|
||||
var (
|
||||
runWithExternalLoop = systray.RunWithExternalLoop
|
||||
setOnTapped = systray.SetOnTapped
|
||||
)
|
||||
|
||||
type systrayBackend struct {
|
||||
mu sync.Mutex
|
||||
end func()
|
||||
iconPath string
|
||||
started bool
|
||||
stopRequested bool
|
||||
}
|
||||
|
||||
type systrayMenuItem struct {
|
||||
item *systray.MenuItem
|
||||
}
|
||||
|
||||
// NewNativeBackend creates the cross-platform native tray backend.
|
||||
// NewNativeBackend creates the cross-platform tray backend. It uses
|
||||
// RunWithExternalLoop so the native tray message loop runs alongside Wails.
|
||||
func NewNativeBackend() Backend {
|
||||
return systrayBackend{}
|
||||
return &systrayBackend{}
|
||||
}
|
||||
|
||||
func (systrayBackend) Register(onReady func(), onExit func()) {
|
||||
systray.Register(onReady, onExit)
|
||||
func (b *systrayBackend) Start(callbacks BackendCallbacks) (err error) {
|
||||
if b == nil {
|
||||
return errBackendUnavailable
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.started {
|
||||
b.mu.Unlock()
|
||||
return errors.New("native tray backend was already started")
|
||||
}
|
||||
b.started = true
|
||||
b.mu.Unlock()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("native tray startup panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
start, end := runWithExternalLoop(callbacks.Ready, callbacks.Exit)
|
||||
if callbacks.LeftClick != nil {
|
||||
setOnTapped(callbacks.LeftClick)
|
||||
}
|
||||
// The Ready callback is asynchronous on Windows and can synchronously
|
||||
// decide that startup failed. Start the native loop before publishing the
|
||||
// end function so a concurrent Stop is never left with a half-started loop.
|
||||
start()
|
||||
b.mu.Lock()
|
||||
stopRequested := b.stopRequested
|
||||
if !stopRequested {
|
||||
b.end = end
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if stopRequested {
|
||||
end()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (systrayBackend) SetIcon(icon []byte) {
|
||||
systray.SetIcon(icon)
|
||||
func (b *systrayBackend) SetIcon(icon []byte) error {
|
||||
if b == nil || len(icon) == 0 {
|
||||
return errors.New("tray icon is empty")
|
||||
}
|
||||
file, err := os.CreateTemp("", "verstak-tray-*"+IconFileExtension())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create tray icon file: %w", err)
|
||||
}
|
||||
path := file.Name()
|
||||
if _, err := file.Write(icon); err != nil {
|
||||
file.Close()
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("write tray icon file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("close tray icon file: %w", err)
|
||||
}
|
||||
if err := systray.SetIconFromFilePath(path); err != nil {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("set native tray icon: %w", err)
|
||||
}
|
||||
b.mu.Lock()
|
||||
previous := b.iconPath
|
||||
b.iconPath = path
|
||||
b.mu.Unlock()
|
||||
if previous != "" && previous != path {
|
||||
_ = os.Remove(previous)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (systrayBackend) SetTooltip(tooltip string) {
|
||||
func (b *systrayBackend) SetTooltip(tooltip string) error {
|
||||
if strings.TrimSpace(tooltip) == "" {
|
||||
return errors.New("tray tooltip is empty")
|
||||
}
|
||||
systray.SetTooltip(tooltip)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem {
|
||||
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
|
||||
func (b *systrayBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return nil, errors.New("tray menu title is empty")
|
||||
}
|
||||
item := systray.AddMenuItem(title, tooltip)
|
||||
if item == nil {
|
||||
return nil, errors.New("native tray menu item is nil")
|
||||
}
|
||||
return systrayMenuItem{item: item}, nil
|
||||
}
|
||||
|
||||
func (systrayBackend) Quit() {
|
||||
systray.Quit()
|
||||
func (b *systrayBackend) Stop() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
end, iconPath := b.end, b.iconPath
|
||||
b.end = nil
|
||||
b.iconPath = ""
|
||||
b.stopRequested = true
|
||||
b.mu.Unlock()
|
||||
if end != nil {
|
||||
end()
|
||||
}
|
||||
if iconPath != "" {
|
||||
_ = os.Remove(iconPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (item systrayMenuItem) Clicked() <-chan struct{} {
|
||||
@@ -39,3 +144,15 @@ func (item systrayMenuItem) Clicked() <-chan struct{} {
|
||||
}
|
||||
return item.item.ClickedCh
|
||||
}
|
||||
|
||||
func (item systrayMenuItem) SetTitle(title string) {
|
||||
if item.item != nil {
|
||||
item.item.SetTitle(title)
|
||||
}
|
||||
}
|
||||
|
||||
func (item systrayMenuItem) SetTooltip(tooltip string) {
|
||||
if item.item != nil {
|
||||
item.item.SetTooltip(tooltip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package tray
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBackendStopsWhenReadyCallbackStopsDuringStartup(t *testing.T) {
|
||||
originalRun := runWithExternalLoop
|
||||
originalSetOnTapped := setOnTapped
|
||||
defer func() {
|
||||
runWithExternalLoop = originalRun
|
||||
setOnTapped = originalSetOnTapped
|
||||
}()
|
||||
|
||||
var startCalls, endCalls int
|
||||
runWithExternalLoop = func(onReady, _ func()) (func(), func()) {
|
||||
onReady()
|
||||
return func() { startCalls++ }, func() { endCalls++ }
|
||||
}
|
||||
setOnTapped = func(func()) {}
|
||||
|
||||
backend := &systrayBackend{}
|
||||
if err := backend.Start(BackendCallbacks{Ready: backend.Stop}); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if startCalls != 1 {
|
||||
t.Fatalf("start calls = %d, want 1", startCalls)
|
||||
}
|
||||
if endCalls != 1 {
|
||||
t.Fatalf("end calls = %d, want 1", endCalls)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -230,6 +230,14 @@ 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())
|
||||
trayController.SetReadyChangedHandler(app.SetTrayReady)
|
||||
trayLabels := func(language string) tray.Labels {
|
||||
return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG"))
|
||||
}
|
||||
trayController.SetLabels(trayLabels(cfg.Language))
|
||||
appSettingsMgr.SetLanguageChangedHandler(func(language string) {
|
||||
trayController.SetLabels(trayLabels(language))
|
||||
})
|
||||
if browserReceiver != nil {
|
||||
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
|
||||
if err != nil {
|
||||
@@ -251,7 +259,9 @@ func main() {
|
||||
OnStartup: app.Startup,
|
||||
OnDomReady: func(ctx context.Context) {
|
||||
app.DomReady(ctx)
|
||||
trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit})
|
||||
if err := trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit}); err != nil {
|
||||
log.Printf("[tray] disabled: %v; normal window close will exit", err)
|
||||
}
|
||||
},
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
trayController.Stop()
|
||||
|
||||
@@ -3,7 +3,7 @@ Version: @VERSION@
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0, libayatana-appindicator3-1
|
||||
Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0
|
||||
Maintainer: Verstak contributors <dev@verstak.app>
|
||||
Homepage: https://github.com/mirivlad/verstak
|
||||
Description: Local-first workspace
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
## Highlights
|
||||
|
||||
- First public alpha of the local Verstak workbench.
|
||||
- Introduces Deals, workspace templates, the Overview, Files, Notes, Journal, activity tracking, browser intake, and the official plugin system.
|
||||
- Includes portable Windows, Debian, and AppImage packages for early testing.
|
||||
|
||||
**Commit history**: https://github.com/mirivlad/verstak/commits/v0.1.0-alpha.1
|
||||
@@ -0,0 +1,7 @@
|
||||
## Highlights
|
||||
|
||||
- Adds scheduled native notifications and Todo reminders.
|
||||
- Keeps Verstak available in the system tray, including the required Linux tray runtime in packages.
|
||||
- Improves the public alpha installation and troubleshooting documentation.
|
||||
|
||||
**Full Changelog**: https://github.com/mirivlad/verstak/compare/v0.1.0-alpha.1...v0.1.0-alpha.2
|
||||
@@ -0,0 +1,7 @@
|
||||
## Highlights
|
||||
|
||||
- Replaces technical workspace terminology in the main interface with the user-facing term “Deal”.
|
||||
- Improves Russian localization, operation errors, template guidance, and the plugin manager filters.
|
||||
- Makes global Journal and Secrets flows usable and improves Todo reminder time selection.
|
||||
|
||||
**Full Changelog**: https://github.com/mirivlad/verstak/compare/v0.1.0-alpha.2...v0.1.0-alpha.3
|
||||
@@ -0,0 +1,9 @@
|
||||
## Highlights
|
||||
|
||||
- The Deal rename dialog now uses the selected application language.
|
||||
- Native tray actions use the selected language and refresh when it changes.
|
||||
- The bundled official plugins include clearer, localized file and folder labels.
|
||||
|
||||
## Packages
|
||||
|
||||
This prerelease provides a portable Windows archive, a Debian package, and an AppImage for Linux.
|
||||
@@ -0,0 +1,31 @@
|
||||
## Highlights
|
||||
|
||||
- Fixed the Windows 10 system-tray integration: the release now embeds a real
|
||||
multi-resolution Windows icon and runs the native tray message loop.
|
||||
- One left click on the tray icon restores and focuses Verstak. A right click
|
||||
opens the native, localized **Show Verstak** and **Quit** menu.
|
||||
- Closing the window now hides it only after the tray is confirmed ready. If
|
||||
the tray cannot start, the app exits normally instead of becoming unreachable.
|
||||
- Replaced the obsolete tray dependency with `fyne.io/systray`, removing the
|
||||
AppIndicator requirement from Linux builds, Debian packages, and AppImages.
|
||||
|
||||
## Главное
|
||||
|
||||
- Исправлена интеграция с системным треем в Windows 10: релиз содержит
|
||||
настоящий многомасштабный значок Windows и запускает нативный цикл сообщений
|
||||
трея.
|
||||
- Один левый клик по значку возвращает и фокусирует Верстак. Правый клик
|
||||
открывает нативное локализованное меню **«Показать Верстак»** и **«Выйти»**.
|
||||
- Окно скрывается только после подтверждённой готовности трея. Если трей не
|
||||
запускается, приложение завершается обычным образом и не остаётся
|
||||
недоступным.
|
||||
- Устаревшая зависимость трея заменена на `fyne.io/systray`; AppIndicator
|
||||
больше не нужен для Linux-сборки, Debian-пакета и AppImage.
|
||||
|
||||
## Packages / Пакеты
|
||||
|
||||
This prerelease provides a portable Windows archive, a Debian package, and an
|
||||
AppImage for Linux.
|
||||
|
||||
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
|
||||
для Linux.
|
||||
@@ -0,0 +1,27 @@
|
||||
## Highlights
|
||||
|
||||
- Replaced the generic Wails image in the Linux tray and Windows application
|
||||
resources with the current Verstak workbench icon.
|
||||
- The desktop build now generates its PNG and multi-resolution ICO resources
|
||||
from the committed Verstak SVG before packaging, preventing a generic icon
|
||||
from returning in a clean build.
|
||||
- The Windows ICO contains 16, 20, 24, 32, 48, and 256 pixel variants; Linux
|
||||
uses the matching transparent PNG tray image.
|
||||
|
||||
## Главное
|
||||
|
||||
- Стандартный знак Wails в Linux-трее и ресурсах Windows заменён на актуальный
|
||||
фирменный значок Верстака.
|
||||
- Перед упаковкой desktop-сборка создаёт PNG и многомасштабный ICO из SVG,
|
||||
хранящегося в репозитории. Это не позволяет стандартному значку вернуться в
|
||||
чистой сборке.
|
||||
- Windows ICO содержит варианты 16, 20, 24, 32, 48 и 256 пикселей; Linux
|
||||
использует соответствующий прозрачный PNG для трея.
|
||||
|
||||
## Packages / Пакеты
|
||||
|
||||
This prerelease provides a portable Windows archive, a Debian package, and an
|
||||
AppImage for Linux.
|
||||
|
||||
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
|
||||
для Linux.
|
||||
@@ -35,6 +35,8 @@ fi
|
||||
cd "$ROOT"
|
||||
echo "=== verstak desktop Windows amd64 build ==="
|
||||
|
||||
"$ROOT/scripts/generate-brand-icons.sh"
|
||||
|
||||
if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
|
||||
if [[ -f "$ROOT/frontend/package-lock.json" ]]; then
|
||||
(cd "$ROOT/frontend" && npm ci --no-audit --no-fund)
|
||||
|
||||
@@ -6,6 +6,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
echo "=== verstak-desktop build ==="
|
||||
echo ""
|
||||
|
||||
"$ROOT/scripts/generate-brand-icons.sh"
|
||||
|
||||
# ── Dependency checks ──
|
||||
echo "[deps]"
|
||||
if ! command -v go &>/dev/null; then
|
||||
@@ -26,15 +28,6 @@ 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]"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MAGICK="${MAGICK_BIN:-magick}"
|
||||
SOURCE="$ROOT/packaging/linux/verstak.svg"
|
||||
|
||||
if ! command -v "$MAGICK" >/dev/null; then
|
||||
echo "ImageMagick is required to generate Verstak application icons: $MAGICK not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$SOURCE" ]]; then
|
||||
echo "Verstak SVG icon source is missing: $SOURCE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
render_png() {
|
||||
local size="$1"
|
||||
local target="$2"
|
||||
mkdir -p "$(dirname "$target")"
|
||||
"$MAGICK" -background none "$SOURCE" -resize "${size}x${size}" "PNG32:$target"
|
||||
}
|
||||
|
||||
render_ico() {
|
||||
local target="$1"
|
||||
shift
|
||||
local temporary
|
||||
temporary="$(mktemp -d)"
|
||||
local images=()
|
||||
for size in "$@"; do
|
||||
local image="$temporary/${size}.png"
|
||||
render_png "$size" "$image"
|
||||
images+=("$image")
|
||||
done
|
||||
mkdir -p "$(dirname "$target")"
|
||||
"$MAGICK" "${images[@]}" "$target"
|
||||
rm -rf "$temporary"
|
||||
}
|
||||
|
||||
render_png 256 "$ROOT/internal/shell/tray/verstak.png"
|
||||
render_ico "$ROOT/internal/shell/tray/verstak.ico" 16 20 24 32 48 256
|
||||
|
||||
# Wails consumes this PNG while it prepares the Windows executable resources.
|
||||
# Both files are generated build inputs and therefore deliberately ignored by Git.
|
||||
render_png 1024 "$ROOT/build/appicon.png"
|
||||
render_ico "$ROOT/build/windows/icon.ico" 16 32 48 64 128 256
|
||||
|
||||
echo "generated Verstak tray and application icons from $SOURCE"
|
||||
@@ -106,11 +106,6 @@ while IFS= read -r candidate; do
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ VERSION="${1:-}"
|
||||
REPOSITORY="mirivlad/verstak"
|
||||
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
|
||||
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
|
||||
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
|
||||
GIT_BIN="${GIT_BIN:-git}"
|
||||
GH_BIN="${GH_BIN:-gh}"
|
||||
|
||||
@@ -65,7 +66,17 @@ 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)
|
||||
NOTES_FILE="$RELEASE_NOTES_DIR/$VERSION.md"
|
||||
if [[ ! -s "$NOTES_FILE" ]]; then
|
||||
echo "human-readable release notes are required: $NOTES_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --verify-tag)
|
||||
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
|
||||
if [[ -n "$PREVIOUS_TAG" ]]; then
|
||||
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
|
||||
fi
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
RELEASE_OPTIONS+=(--prerelease)
|
||||
else
|
||||
|
||||
@@ -26,11 +26,16 @@ grep -Fq 'gtk-update-icon-cache' "$ROOT/packaging/deb/postinst"
|
||||
grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh"
|
||||
grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun"
|
||||
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control"
|
||||
grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control"
|
||||
if grep -Fq 'appindicator' "$ROOT/packaging/deb/control"; then
|
||||
echo "Debian package must not require the removed AppIndicator tray backend" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh"
|
||||
grep -Fq '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 'appindicator' "$ROOT/scripts/package-appimage.sh" "$ROOT/scripts/build.sh"; then
|
||||
echo "Linux build and AppImage scripts must not require AppIndicator" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then
|
||||
echo "Windows portable archive must not bundle Fixed Version WebView2" >&2
|
||||
exit 1
|
||||
@@ -48,6 +53,8 @@ grep -Fq 'LinkId=2124701' "$ROOT/README.md"
|
||||
grep -Fq 'WebView2 Runtime' "$ROOT/README.md"
|
||||
grep -Fq 'package-deb.sh' "$ROOT/scripts/release.sh"
|
||||
grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh"
|
||||
grep -Fq 'generate-brand-icons.sh' "$ROOT/scripts/build.sh"
|
||||
grep -Fq 'generate-brand-icons.sh' "$ROOT/scripts/build-windows.sh"
|
||||
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"
|
||||
|
||||
@@ -18,9 +18,10 @@ if [[ ! -x "$PUBLISHER" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
|
||||
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state"
|
||||
LOG="$WORK/log"
|
||||
export LOG
|
||||
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
|
||||
|
||||
cat > "$WORK/release.sh" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
@@ -51,6 +52,7 @@ case "${1:-}" in
|
||||
fi
|
||||
echo test-commit
|
||||
;;
|
||||
describe) echo v0.0.0-previous ;;
|
||||
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 ;;
|
||||
@@ -76,6 +78,7 @@ chmod +x "$WORK/bin/gh"
|
||||
run_publisher() {
|
||||
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
|
||||
VERSTAK_RELEASE_DIR="$WORK/release" \
|
||||
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
|
||||
GIT_BIN="$WORK/bin/git" \
|
||||
GH_BIN="$WORK/bin/gh" \
|
||||
EXPECTED_ROOT="$ROOT" \
|
||||
@@ -92,6 +95,9 @@ for asset in "${ASSET_NAMES[@]}"; do
|
||||
grep -F "$asset" "$LOG" >/dev/null
|
||||
done
|
||||
grep -F "SHA256SUMS" "$LOG" >/dev/null
|
||||
grep -F -- "--notes-file $WORK/release-notes/$VERSION.md" "$LOG" >/dev/null
|
||||
grep -F -- "--generate-notes" "$LOG" >/dev/null
|
||||
grep -F -- "--notes-start-tag v0.0.0-previous" "$LOG" >/dev/null
|
||||
grep -F -- "--prerelease" "$LOG" >/dev/null
|
||||
|
||||
run_publisher
|
||||
|
||||