# VNote Agent Development Guide ## Prerequisites - **Git** (with Git Bash on Windows) - **CMake** 3.20+ - **Qt** 5.x or 6.x (with QtWebEngine) - **C++14** compatible compiler (MSVC, GCC, Clang) - **clang-format** (optional, for automatic code formatting) ## Setup After cloning the repository, run the init script: | Platform | Command | |----------|---------| | Linux/macOS | `bash scripts/init.sh` | | Windows | `scripts\init.cmd` | The init script: 1. Initializes and updates git submodules recursively 2. Installs pre-commit hook for automatic clang-format on staged C++ files 3. Sets up vtextedit submodule pre-commit hook ## Submodule Push Discipline (CRITICAL — read before every push) VNote pins git submodules (`libs/vxcore`, `libs/vtextedit`, `libs/QHotkey`, `libs/qwindowkit`) to specific commits. When you change code inside a submodule, the parent repo records a new submodule pointer (a gitlink to a commit SHA). **CI clones submodules from their own remotes** — if the pinned commit only exists in your local submodule checkout, every CI job fails at the "Init Submodules" step with: ``` fatal: remote error: upload-pack: not our ref fatal: Fetched in submodule path 'libs/vxcore', but it did not contain . Direct fetching of that commit failed. ``` This breaks **all** CI checks (Linux, Windows, macOS, TSan) before any build runs. ### Rule: ALWAYS push the submodule FIRST, then the parent repo. When a commit bumps a submodule pointer, the order is non-negotiable: 1. **Push the submodule remote first.** From inside the submodule (e.g. `cd libs/vxcore`), push its branch so the pinned commit exists upstream: ```bash cd libs/vxcore git push origin HEAD:main # or the appropriate branch cd ../.. ``` 2. **Then push the parent vnote repo.** Only after the submodule commit is on its remote may you push the gitlink that references it. To push both at once and let git verify submodules are reachable, use: ```bash git push --recurse-submodules=on-demand ``` Or enforce it as a guard before any push (recommended once per clone): ```bash git config push.recurseSubmodules check # aborts the parent push if a submodule commit is unpushed ``` ### Before pushing, verify no submodule commit is stranded ```bash # For each submodule, confirm local HEAD is not ahead of its remote: git submodule foreach 'git status -sb' # A line like "## main...origin/main [ahead 1]" means an UNPUSHED submodule commit — push it before pushing vnote. # Confirm the pinned SHA exists on the submodule remote: cd libs/vxcore && git branch -r --contains $(git rev-parse HEAD) && cd ../.. # Empty output = the commit is NOT on any remote branch yet. DO NOT push vnote until it is. ``` If CI is already failing with "not our ref", the fix is to push the missing submodule commit (do **not** roll back the parent pointer if newer parent commits depend on the new submodule API). ## Build Commands ### Release Build ```bash mkdir build && cd build cmake .. cmake --build . --config Release ``` ### Debug Build ```bash mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug cmake --build . --config Debug ``` ### Windows (PowerShell) ```powershell New-Item -ItemType Directory -Force -Path build Set-Location build cmake .. -GNinja cmake --build . --config Release ``` ### Clean Build ```bash rm -rf build && mkdir build && cd build && cmake .. && cmake --build . ``` ### Packaging-Only Build (Skip Tests) For CI builds that only produce artifacts and don't need to compile the test infrastructure (e.g., to avoid hard-coded Qt6 test code when building against Qt5), pass the `VNOTE_BUILD_TESTS` option: ```bash cmake .. -DVNOTE_BUILD_TESTS=OFF cmake --build . ``` --- ## Testing VNote has tests in **two separate locations** with different registration helpers and build configurations. Pick the right one or your test will silently never run. ### Parent VNote tests (`tests/`) Built automatically with the parent project into `build-debug/tests//`. Registered via the `add_qt_test()` helper. **Add a new test:** 1. Create `tests//test_yourthing.cpp` (categories: `controllers/`, `core/`, `gui/`, `integration/`, `models/`, `utils/`, `widgets/`). 2. Register in the same directory's `CMakeLists.txt`: ```cmake add_qt_test(test_yourthing SOURCES test_yourthing.cpp ${CMAKE_SOURCE_DIR}/src/path/to/code_under_test.cpp LINKS core_services vxcore GUILESS # omit if test needs QApplication / widgets ) ``` 3. Reconfigure from repo root if `tests/CMakeLists.txt` itself changed: `cmake -B build-debug`. **Build + run:** ```powershell cmake --build build-debug --config Debug --target test_yourthing ctest --test-dir build-debug -C Debug -R "^test_yourthing$" --output-on-failure ``` ### vxcore submodule tests (`libs/vxcore/tests/`) **Separate build dir required** — the parent `build-debug/` does NOT compile vxcore tests. Use a dedicated build dir configured with `-DVXCORE_BUILD_TESTS=ON`. Each test is a standalone executable (file basename = test target name) registered via `add_vxcore_test()`. **Add a new test:** 1. Create `libs/vxcore/tests/test_yourthing.cpp` with its own `main()`. Subtests are functions that return `int` (0 = pass); call them via `RUN_TEST(...)` from `test_utils.h`. Always start `main()` with `vxcore_set_test_mode(1)` so the test uses `%TEMP%\vxcore_test*` instead of real AppData. 2. Register in `libs/vxcore/tests/CMakeLists.txt`: ```cmake add_vxcore_test(test_yourthing) ``` The helper auto-links `vxcore` and adds `${CMAKE_SOURCE_DIR}/src` + `third_party` include dirs. 3. Test executables CANNOT call vxcore-internal symbols that lack `VXCORE_API` — they live outside the DLL. If you need an internal helper (e.g., `GetCurrentTimestampMillis()`), re-derive it locally in the test file rather than exporting it. **Configure (once per machine, or after CMake version upgrade):** ```powershell cmake -S libs/vxcore -B libs/vxcore/build_test -G "Visual Studio 17 2022" -A x64 -DVXCORE_BUILD_TESTS=ON ``` **Build + run:** ```powershell cmake --build libs/vxcore/build_test --config Debug --target test_yourthing ctest --test-dir libs/vxcore/build_test -C Debug -R "^test_yourthing$" --output-on-failure ``` ### Universal rules - **Anchor the ctest regex** with `^...$` to avoid false matches (e.g., `-R "^test_sync$"` won't sweep in `test_session_persistence`). - After a CMake version upgrade, stale build dirs fail to reconfigure with errors like `CMakeSystem.cmake.in does not exist`. Delete the offending build dir and re-run the configure command from scratch — do NOT try to repair in place. - After modifying a touched module, run the FULL test target for that module (not just your new subtest) to catch regressions; vxcore tests print each subtest name to stdout so you can verify the new one ran. ### Running execs that depend on VTextEdit.dll (CRITICAL — avoid false-positive smoke tests) `build-debug/src/vnote.exe` and the widget/editor-level `build-debug/tests//test_*.exe` execs link against `VTextEdit.dll` (built into `build-debug/libs/vtextedit/src/`) plus the Qt 6 runtime DLLs. Pure-core tests that link only `core_services` + `vxcore` do NOT (see [src/core/services/AGENTS.md](src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract)). Neither DLL set is automatically copied next to `vnote.exe` in this development build (only test-exec dirs get VTextEdit.dll copied via CMake target propagation). Launching `vnote.exe` without setting PATH first causes the Windows loader to pop a "VTextEdit.dll was not found" modal dialog BEFORE the process reaches `WinMain`. **This is a verification trap**: when the loader dialog blocks the process, the OS reports the process as alive (it has a PID, is technically running), so naive checks like `Start-Process … -PassThru` + `Sleep` + `HasExited` return `$false` → you falsely conclude the binary started. The process is actually frozen in pre-WinMain limbo waiting for someone to click the dialog. `Stop-Process -Force` afterwards silently dismisses the dialog and the lie is preserved. **Correct smoke-test pattern (PowerShell):** ```powershell # 1. Prepend Qt bin + VTextEdit dir to PATH so the loader resolves all DLLs. $env:PATH = "C:/Qt/6.9.3/msvc2022_64/bin;" + "$PWD/build-debug/libs/vtextedit/src;" + $env:PATH # 2. Launch vnote.exe. $proc = Start-Process -FilePath "build-debug/src/vnote.exe" -PassThru -WindowStyle Hidden Start-Sleep -Seconds 5 # 3. Verify the process actually loaded VTextEdit.dll — NOT just HasExited. # If the loader dialog blocked the process, $proc.Modules will throw or be empty. $loaded = $false try { $proc.Refresh() $loaded = ($proc.Modules | Where-Object { $_.ModuleName -ieq "VTextEdit.dll" }).Count -gt 0 } catch {} if ($proc.HasExited) { Write-Output "FAIL: vnote.exe exited with code $($proc.ExitCode) within 5s" } elseif (-not $loaded) { Write-Output "FAIL: vnote.exe alive but VTextEdit.dll not loaded — likely a loader dialog" } else { Write-Output "PASS: vnote.exe alive with VTextEdit.dll loaded after 5s" } Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue ``` The same PATH prepend is required before running parent `test_*.exe` directly (per `tests/AGENTS.md`). `ctest --test-dir build-debug` inherits the caller's PATH, so set it once at the start of the session. vxcore submodule tests (`libs/vxcore/build_test/bin/Debug/test_*.exe`) do NOT depend on Qt or VTextEdit and need no PATH setup. --- ## Architecture Overview VNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency injection for testability and future plugin support. ### Core Principles 1. **MVC Separation** — Models hold data, Views display it, Controllers handle logic 2. **Dependency Injection** — No singletons; dependencies passed via ServiceLocator 3. **Service Layer** — Business logic encapsulated in services, accessed via ServiceLocator 4. **Hook System** — WordPress-style extensibility for plugins ### MVC Architecture (CRITICAL) VNote strictly follows the MVC pattern. **All new code MUST adhere to this structure.** ``` ┌─────────────────────────────────────────────────────────────┐ │ Controllers │ │ (src/controllers/ - Handle user actions, business logic) │ │ │ │ NotebookNodeController, NewNoteController, etc. │ └─────────────────────────────────────────────────────────────┘ │ │ │ Manipulates │ Emits signals to ▼ ▼ ┌───────────────────────┐ ┌──────────────────────────────┐ │ Models │ │ Views │ │ (src/models/) │◄──────│ (src/views/) │ │ │ │ │ │ NotebookNodeModel │ Data │ NotebookNodeView │ │ (QAbstractItemModel) │ flows │ (QTreeView subclass) │ └───────────────────────┘ └──────────────────────────────┘ │ │ │ Fetches data from │ Receives from ▼ ▼ ┌─────────────────────────────────────────────────────────────┐ │ ServiceLocator │ │ (DI container - NOT a singleton, passed by reference) │ │ │ │ ┌─────────────────┐ ┌─────────────────────┐ ┌───────────────────┐ │ │ │ConfigCoreService│ │ NotebookCoreService │ │SearchCoreService │ │ │ └─────────────────┘ └─────────────────────┘ └───────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ vxcore │ │ (C library: notebook/config/search backend in libs/vxcore) │ └─────────────────────────────────────────────────────────────┘ ``` ### MVC Responsibilities | Layer | Location | Responsibility | Example | |-------|----------|----------------|---------| | **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` | | **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal | | **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations | | **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD | ### MVC Example: Notebook Node Operations ```cpp // Controller handles user action void NotebookNodeController::newNote(const NodeIdentifier &p_parentId) { // 1. Emit signal to View to show dialog emit newNoteRequested(p_parentId); } // View shows dialog, then calls back to Controller void NotebookExplorer2::onNewNoteResult(const NodeIdentifier &p_parentId, const NodeIdentifier &p_newNodeId) { m_controller->handleNewNoteResult(p_parentId, p_newNodeId); } // Controller updates Model void NotebookNodeController::handleNewNoteResult(const NodeIdentifier &p_parentId, const NodeIdentifier &p_newNodeId) { // Model reloads from NotebookCoreService m_model->reloadNode(p_parentId); } ``` ### MVC Rules (MUST FOLLOW) | Rule | Rationale | |------|-----------| | **Models MUST NOT** contain UI logic | Models are reusable across different views | | **Views MUST NOT** modify data directly | Views only display and emit signals | | **Controllers MUST NOT** inherit from QWidget | Controllers are testable without GUI | | **All layers receive `ServiceLocator&`** | Enables dependency injection and testing | | **Use signals/slots between layers** | Loose coupling between M, V, C | ### Key Design Decisions | Decision | Rationale | |----------|-----------| | ServiceLocator is NOT a singleton | Enables testing with mock services; explicit dependencies | | Services wrap vxcore C API | Qt-friendly interface; encapsulates C interop | | Controllers are QObject, not QWidget | Testable business logic without GUI dependencies | | Widgets receive `ServiceLocator&` | Constructor injection; no global state | | Some files carry a `2` suffix (`MainWindow2`, `Buffer2`, …) | Historical artifact of the now-complete migration off the legacy singleton architecture. The pre-migration counterparts have been removed; the suffix is retained on those existing classes only to avoid a churny rename. The migration is finished, so **new code uses the plain, unsuffixed name unless it genuinely conflicts with an existing type** (e.g. a brand-new `EncodingButton` gets no suffix). Never introduce a `3` suffix. | | `Buffer2` is a lightweight copyable handle (like `QModelIndex`) | Returned by `BufferService::openBuffer()`, delegates to `BufferCoreService`; NOT a `QObject`, not heap-allocated | | `BufferService` privately inherits `BufferCoreService` | Hook-aware wrapper that fires `vnote.file.*` hooks around core operations | | `NodeIdentifier` is a standalone value type | Identifies a node by `notebookId` + `relativePath`; used by `Buffer2`, controllers, and views | | `ConfigMgr2` is the ONLY way to access typed config | Owns `MainConfig`/`SessionConfig` with properly merged defaults. NEVER construct a throwaway `MainConfig` from raw JSON — use `m_services.get()` instead. See `src/core/AGENTS.md` for details. | | `ConfigMgr2::getFileFromConfigFolder()` for path resolution | Resolves relative config paths (e.g., `"web/markdown-viewer-template.html"`) against the app data directory. Do NOT use `ConfigCoreService::getDataPath()` + manual `QDir::filePath()`. | | `PathExists()`/`IsDirectory()`/`IsRegularFile()` wrappers | NEVER pass raw `std::string` to `std::filesystem` — use these wrappers or `PathFromUtf8()` for non-ASCII path safety on Windows | ### Key Design Decisions (ViewArea2 Framework) | Decision | Rationale | |----------|-----------| | `ViewAreaController` is the orchestrator | Handles open/close/split/move logic, fires hooks, uses WorkspaceCoreService | | `ViewArea2` is a pure view | Owns QSplitter tree + ViewSplit2 instances, no business logic | | `ViewSplit2` maps 1:1 to vxcore workspace | Each tab widget pane corresponds to one `WorkspaceCoreService` workspace | | `ViewWindow2` receives `Buffer2` in constructor | Not attach/detach pattern; one window = one buffer for its lifetime | | `ViewWindowFactory` maps file types to creators | Registry pattern; plugins register creators for new file types | | Splitter orientation follows Vim convention | Left/Right split → `Qt::Horizontal`, Up/Down split → `Qt::Vertical` | | Session layout stored as JSON in `SessionConfig` | Recursive splitter tree + workspace IDs for full layout persistence | | Concrete ViewWindows live alongside the framework | `MarkdownViewWindow2`, `TextViewWindow2`, `PdfViewWindow2`, `MindMapViewWindow2`, and `WidgetViewWindow2` are registered with `ViewWindowFactory` per file type | ### Directory Structure ``` src/ ├── main.cpp # Entry point with DI wiring ├── core/ │ ├── servicelocator.h # DI container │ ├── nodeidentifier.h # Lightweight node ID (notebookId + relativePath) │ ├── services/ # Service layer (wraps vxcore) │ │ ├── configcoreservice.h/.cpp │ │ ├── notebookcoreservice.h/.cpp │ │ ├── searchcoreservice.h/.cpp │ │ ├── filetypecoreservice.h/.cpp │ │ ├── buffercoreservice.h/.cpp │ │ ├── bufferservice.h/.cpp # Hook-aware wrapper, returns Buffer2 │ │ ├── buffer2.h/.cpp # Lightweight buffer handle (like QModelIndex) │ │ ├── templateservice.h/.cpp │ │ ├── historyservice.h/.cpp # Aggregate per-notebook history across notebooks │ │ ├── workspacecoreservice.h/.cpp # Workspace operations (split pane ↔ vxcore workspace) │ │ └── hookmanager.h/.cpp │ ├── hookcontext.h # Hook callback context │ ├── hooknames.h # Hook name constants │ ├── configmgr2.h/.cpp # High-level config manager using DI │ └── iconfigmgr.h # Interface for config managers ├── gui/ # GUI-aware services and utilities │ ├── services/ │ │ ├── themeservice.h/.cpp # GUI-aware theme management service │ │ └── viewwindowfactory.h/.cpp # Registry mapping file types to ViewWindow2 creators │ └── utils/ │ ├── widgetutils.h/.cpp # Widget utility helpers │ ├── themeutils.h/.cpp # Theme utility helpers │ ├── imageutils.h/.cpp # Image utility helpers │ └── guiutils.h/.cpp # General GUI utilities ├── models/ # Qt Model/View models │ ├── notebooknodemodel.h/.cpp # QAbstractItemModel for node hierarchy │ └── notebooknodeproxymodel.h/.cpp # Proxy model for sorting/filtering ├── views/ # Qt views and delegates │ ├── notebooknodeview.h/.cpp # QTreeView for nodes │ ├── notebooknodedelegate.h/.cpp # Item delegate for node rendering │ ├── combinednodeexplorer.h/.cpp # Composite MVC wiring widget │ └── filenodedelegate.h/.cpp # Item delegate for file list ├── controllers/ # Controllers (business logic mediators) │ ├── notebooknodecontroller.h/.cpp # Node operations controller │ ├── newnotecontroller.h/.cpp # New note dialog controller │ ├── newfoldercontroller.h/.cpp # New folder dialog controller │ ├── newnotebookcontroller.h/.cpp # New notebook dialog controller │ ├── opennotebookcontroller.h/.cpp # Open notebook flow controller │ ├── managenotebookscontroller.h/.cpp │ ├── importfoldercontroller.h/.cpp │ ├── recyclebincontroller.h/.cpp │ └── viewareacontroller.h/.cpp # View area orchestrator (open/close/split/move) ├── widgets/ # UI widgets (views receiving ServiceLocator&) │ ├── mainwindow2.h/.cpp # Main window shell │ ├── notebookexplorer2.h/.cpp │ ├── notebookselector2.h/.cpp │ ├── toolbarhelper2.h/.cpp │ ├── viewwindow2.h/.cpp # Abstract base for file viewer windows │ ├── markdownviewwindow2.h/.cpp # Markdown editor/preview window │ ├── textviewwindow2.h/.cpp # Plain text editor window │ ├── pdfviewwindow2.h/.cpp # PDF viewer window │ ├── mindmapviewwindow2.h/.cpp # Mind map viewer window │ ├── widgetviewwindow2.h/.cpp # Generic widget-hosting window │ ├── viewsplit2.h/.cpp # QTabWidget-based split pane (one vxcore workspace) │ ├── viewarea2.h/.cpp # Splitter tree view (manages ViewSplit2 layout) │ └── dialogs/ # Dialog widgets │ ├── newnotedialog2.h/.cpp │ ├── newfolderdialog2.h/.cpp │ ├── newnotebookdialog2.h/.cpp │ ├── managenotebooksdialog2.h/.cpp │ └── importfolderdialog2.h/.cpp ├── net/ │ └── networkutils.h/.cpp # core_net: Qt Core/Network-only HTTP helpers │ # (vnotex::NetworkUtils / NetworkReply / NetworkAccess) ├── utils/ │ └── fileutils2.h/.cpp # File utilities └── ... ``` --- ## Sync State Model Threading rules: see `libs/vxcore/src/sync/AGENTS.md` § Threading & Callback Contract. Qt-side dispatch (single queue via `SyncWorkQueueManager` + `SyncOps`, coalescing, cancellation, auto-sync routing through `EventBridge::syncShouldRun`): see `src/core/services/AGENTS.md` § SyncService. The per-notebook `autoSyncEnabled` flag (boolean, default true) is a pure on/off gate inside vxcore's `MaybeEnqueueSync`: when false, vxcore suppresses `sync.should_run` entirely. It carries no cadence. Auto-sync cadence is owned Qt-side by `SyncService`, which applies a trailing-throttle debounce keyed off the global `autoSyncDebounceSeconds` app-config value (stored in vxcore's `vxcore.json` but consumed only by VNote). Notebook sync has 8 reachable states (S0-S7). Every controller, widget, and service that touches sync must reason in terms of these states. The state is the tuple of: on-disk JSON sync fields, PAT presence in the OS keychain, and runtime registration in vxcore's `states_` map. ### Canonical State Predicates | State | syncEnabled (JSON) | syncBackend (JSON) | syncRemoteUrl (JSON) | PAT in keychain | states_ entry | |---|---|---|---|---|---| | S0 | false / absent | absent | absent | absent | absent | | S1 | true | "git" | empty | maybe | absent | | S2 | true | "git" | set | **absent** | absent | | S3 | true | empty | maybe | maybe | absent | | S4 | true | "git" | set | present | **absent** | | S5 | true | "git" | set | present | present | | S6 | false | absent | absent | **present** | absent | | S7 | true | "git" | set | present | present + active sync | S5 is the only "ready" state. S1-S4 and S6 are partial/inconsistent; S0 is cleanly disabled; S7 is in-flight. F3.5 in-flight sub-states (fetching/resolving/pushing) are NOT modeled as separate SyncState values, they remain runtime properties exposed by SyncService progress signals while the notebook is in S7. ### Recovery Paths: bootstrapApply vs applyChanges | Path | Use when | Behavior | |---|---|---| | `NotebookSyncInfoController::bootstrapApply(url, pat)` | Notebook is in S1/S2/S3/S4 (any partial state). Atomic enable for an existing notebook. | Calls `SyncService::enableSyncForNotebook` directly; on success persists `syncRemoteUrl` and triggers initial sync; on failure keeps notebook in current state (NO delete, unlike `NewNotebookController::bootstrapSync`). | | `NotebookSyncInfoController::applyChanges(url, pat)` | Notebook is in S5 (registered). PAT refresh or URL change. | PAT-only update routes through `SyncService::updateCredentials`. URL change triggers `confirmUrlChangeRequested` signal and, on confirm, runs atomic disable+wipe `vx_notebook/vx_sync/`+re-enable. | The dialog (`NotebookSyncInfoDialog2`) auto-routes to `bootstrapApply` when `m_bootstrapMode == true` OR when `SyncService::isSyncRegistered(id) == false`. This is defense in depth: even when a caller bypasses the bootstrap entry point, partial-state notebooks still get the atomic path. ### Reconcile Semantics `SyncService::reconcileSyncForNotebook` is called by `MainWindowAfterStart` and on notebook open to lift S4 notebooks (disk-complete, runtime-absent) into S5. Key invariants (`src/core/services/syncservice.cpp:858-970`): - `m_reconcileAttempted.insert(id)` happens **after** all precondition checks pass (line 893), not before. The disk-enabled check (line 869), idempotence guard (line 875), and complete-config check (line 884) all run first; any of them returning early leaves the attempted set untouched. Precondition failures therefore do NOT block future retries. Concrete consequence: a notebook in S1/S3 (enabled but no backend/url, or no backend) hits the `incomplete config` branch at line 885, emits `reconcileFinished(VXCORE_ERR_INVALID_PARAM)`, and is NOT marked attempted. When the user later supplies the missing URL via `bootstrapApply` and the notebook reaches S4, the very next reconcile trigger (notebook open or app start) will pass the precondition check and proceed. - `m_reconcileAttempted.remove(id)` fires on transient PAT fetch failure (line 945) so the next reconcile call retries. The same key is re-cleared by `updateCredentials` (line 969) before manually re-driving reconcile, so a user re-entering a fresh PAT never hits the "already attempted" guard. - No remove on success (notebook is registered; no retry needed). - Idempotence check at line 875 prevents duplicate in-flight reconciles when `MainWindowAfterStart` and `NotebookAfterOpen` race. **Post-reconcile freshness gate (auto-sync on open / app start).** After reconcile's `SyncOps::enableSync` work item returns `VXCORE_OK`, `SyncService::maybeTriggerPostReconcile(notebookId)` (`src/core/services/syncservice.cpp`) optionally enqueues a follow-up `triggerSyncNow` so the notebook is auto-synced when the user reopens VNote (or opens a notebook for the first time in the session) after remote changes. Closes the multi-device staleness window where reconcile alone only registered the notebook and the first `FetchOrigin` waited for the next save / manual Sync Now. The gate skips when: shutdown is in progress; the notebook is no longer enabled or registered; a sync is already in flight for the notebook; or the per-device last successful sync timestamp is newer than `kPostReconcileFreshnessMs` (2 minutes — covers rapid open/close cycles without thrashing). Both L1 (`onMainWindowAfterStart`, per-notebook sweep) and L2 (`onNotebookAfterOpen`, single notebook) inherit this behavior since both call `reconcileSyncForNotebook`. Full rationale and test seams live in `src/core/services/AGENTS.md` § "Post-reconcile freshness gate (`maybeTriggerPostReconcile`)". ### bootstrapAndPersist Rollback × Reconcile `SyncService::bootstrapAndPersist` (`syncservice.cpp:409-508`) is the atomic enable+persist path used by `NewNotebookController` (W13.4, F1.6). On persist failure AFTER vxcore enable already succeeded, it issues a rollback by calling `disableSyncForNotebook`, which per "Disable Cleanup" below clears the three flat sync JSON keys then deletes the keychain entry. Interaction with reconcile: - **Rollback succeeds** (the common case): the notebook returns to clean S0. The disk-enabled check at `reconcileSyncForNotebook` line 869 fails immediately, the function early-returns, `m_reconcileAttempted` is never touched, and reconcile is correctly a no-op. The notebook needs a fresh user-initiated bootstrap, not silent re-registration. This is the intended recovery story. - **Rollback fails** (rare; both persist AND disable failed, logged at `qCritical` line 497): the notebook is left in a partial state. If JSON still has all three keys set (vxcore enable succeeded, JSON write succeeded for some keys, then disable failed leaving keys intact), the notebook is effectively in S4. On the next reconcile trigger, all precondition checks pass, `m_reconcileAttempted` gets set, and reconcile will attempt to register the notebook. This is the expected recovery path for that edge case; the loud `qCritical` log gives operators a chance to investigate. The key property: rollback NEVER causes reconcile to silently resurrect a notebook the user wanted disabled. Either rollback succeeded (so disk is clean and reconcile bails) or rollback failed (so disk truthfully says "enabled" and reconcile correctly tries to complete the job). ### Disable Cleanup `SyncService::disableSyncForNotebook` on `VXCORE_OK` clears all three flat sync keys (`syncEnabled`, `syncBackend`, `syncRemoteUrl`) from notebook JSON BEFORE deleting the keychain entry (`src/core/services/syncservice.cpp:246-290`). On failure, JSON is preserved for retry. This closes the "resurrection trap" where a disabled notebook would reappear as S6 (orphan PAT) or S1 (orphan disk fields) on next app start. For the full table of all five credential cleanup sites (bootstrap rollback, `bootstrapAndPersist` rollback, notebook removal, sync disable, S6 startup sweep) and the "when fires / when does NOT fire" matrix, see [src/core/services/AGENTS.md § Credential Cleanup Invariants](src/core/services/AGENTS.md#credential-cleanup-invariants). ### Startup S6 Sweep `SyncService::onMainWindowAfterStart` (`syncservice.cpp:828-854`) sweeps S6 orphans before reconciling. For each notebook it iterates, if `!isSyncEnabled(id) && m_credentialsStore->hasCredentials(id)` (the S6 predicate: disk says disabled but a PAT is still in the keychain), it calls `m_credentialsStore->deleteCredentials(id)` to drop the orphan PAT. This handles the scenario where a previous session's disable succeeded inside vxcore but the app crashed (or was killed) before the keychain delete completed, leaving an orphan PAT that the new "disable clears JSON then keychain" ordering would otherwise not catch on its own. The sweep runs BEFORE `reconcileSyncForNotebook(nbId)` on each notebook, so by the time reconcile examines the notebook the keychain state is consistent with disk truth. ### Re-enable UI Affordance S0 notebooks expose a re-enable surface via the same Sync button and Sync Info menu used for S5 (`src/widgets/notebookexplorer2.cpp:1512-1635`). For S0: - Button label: "Enable Sync" (distinct from "Sync Now" for S5) - On click: opens `NotebookSyncInfoDialog2` with `setBootstrapMode(true)` and all fields empty - Sync Info menu item enabled regardless of `syncEnabled` (dialog opens in bootstrap mode with disable button hidden) Without this affordance, users who disable sync cannot re-enable without recreating the notebook. ### Sync Architecture Layers VNote consumes vxcore as an embedded library following the contract documented in `libs/vxcore/AGENTS.md` § Library Integration Contract. Vxcore emits facts (events, dirty marks); VNote owns sync scheduling policy via `SyncService` + `SyncWorkQueueManager` (see `src/core/services/AGENTS.md` § SyncService). Vxcore must NOT contain Qt-side concerns (no `QTimer`, no `QObject`, no scheduling policy); VNote must NOT bypass the contract by reaching into vxcore internals (no direct backend calls, no touching libgit2, no `states_` mutation). The 4-layer ownership table lives in the vxcore doc to avoid duplication. **Qt-side scheduling shape (post May 2026 audit, debounce added June 2026).** `SyncService::onSyncShouldRun` no longer enqueues immediately on the auto-sync path. It keeps the shutdown / readiness / auth-circuit-breaker guards, then routes through a per-notebook trailing-throttle debounce: when the global cadence `autoSyncDebounceSeconds` is `0` it enqueues immediately, otherwise it arms a per-notebook single-shot `QTimer` (`armOrIgnoreDebounce`) whose delay is `lastSyncMs + autoSyncDebounceSeconds*1000 - now`. An already-active timer is kept (the burst is absorbed), and `onDebounceTimeout` re-reads the cadence and re-checks freshness at fire time, re-arming if the last sync is still inside the window before finally calling the shared `enqueueAutoSync` body (`coalesceKey="trigger"`). The cadence is read on demand from `ConfigCoreService::getAutoSyncDebounceSeconds()` (clamped `[0, 86400]`), so config edits take effect without restart. Manual "Sync Now" (`triggerSyncNow`) and the post-reconcile freshness trigger BYPASS the debounce entirely. The coalesce key still dedupes whatever lands in the queue. The debounce lives one layer ABOVE `SyncWorkQueueManager`, so queue semantics are unchanged. vxcore's per-notebook `autoSyncEnabled` is a boolean on/off gate only (it suppresses `sync.should_run` when false) and carries no schedule. --- ## Save Path Threading Contract The `Buffer2` / [`BufferService`](src/core/services/bufferservice.h) auto-save path used to call `vxcore_buffer_save` inline on the UI thread, so any slow filesystem operation (large file flush, virus scanner, network drive, antivirus quarantine) froze the editor. That synchronous call now runs on a worker via [`BufferSaveQueue`](src/core/services/buffersavequeue.h). The UI thread's job is reduced to: snapshot the current content plus a monotonically increasing revision, call `BufferSaveQueue::enqueue(...)`, and return. No disk I/O on the UI thread. Save work and any git-stage / git-commit work on the SAME notebook are serialized by [`NotebookIoGate`](src/core/services/notebookiogate.h), a per-notebook async mutex. `BufferSaveQueue` workers acquire `NotebookIoGate::ScopedLock(notebookId)` for the full duration of their disk write. [`SyncOps::triggerSync`](src/core/services/syncops.cpp) now runs sync as two phases: it holds the gate ONLY around [`vxcore_sync_stage_only`](libs/vxcore/include/vxcore/vxcore.h) (StageAll + CommitIndex, working-tree-touching), then releases it BEFORE calling [`vxcore_sync_network_phase`](libs/vxcore/include/vxcore/vxcore.h) (FetchOrigin + RebaseOntoOrigin + PushOrigin). The result: a sync never reads a half-written file, a save never lands inside someone else's `git add`/commit, AND a queued save on the same notebook resumes the moment the local commit lands, regardless of how long the network round-trip takes. vxcore's `mark_dirty` → `MaybeEnqueueSync` → `Emit("sync.should_run")` chain remains synchronous on the caller thread BY DESIGN. In steady state it is microseconds, and pushing it onto another thread would buy nothing while costing event-ordering guarantees. **This contract does NOT change vxcore.** The threading discipline is consumer-side only: keep `vxcore_buffer_save` off the UI thread, and the `mark_dirty` tail it triggers stays off the UI thread for free. > **Forbidden Patterns (post-T7):** > - Calling `vxcore_buffer_save` directly from the UI thread. Use [`BufferSaveQueue::enqueue`](src/core/services/buffersavequeue.h) instead. > - Touching a notebook's working tree (save, stage, commit, checkout) without holding `NotebookIoGate::ScopedLock(notebookId)`. --- ## Search Threading Contract Content search in vxcore owns NO thread pool. As of the streaming-search work, `vxcore_search_content` / `vxcore_search_content_ex` / `vxcore_search_content_streaming` enqueue ONE work item per FILE-CHUNK (default `kDefaultSearchChunkSize = 64` files, tunable via the streaming `batch_size` parameter) onto a dedicated `"vxcore.search"` `WorkQueue` that is pre-created at `vxcore_context_create`. The CALLER's threads drain that queue, and the initiating thread help-drains its own enqueued items (caller-helps-drain: it loops `ProcessNext(5ms)` until the batch is done). VNote's [`SearchService`](src/core/services/searchservice.h) owns the drain pool that loops `vxcore_work_queue_process_next(ctx, "vxcore.search", 100)`. A search that fits in a single chunk (`fileCount <= batch_size`, i.e. ≤ 64 files by default) runs inline sequentially on the calling thread; larger searches fan out one queued item per chunk. Chunking subsumes the former `kParallelSearchThreshold` (was 50 files): the chunk boundary is now both the parallelism unit AND the incremental-delivery unit. This coarsens parallelism granularity for medium searches versus the old per-file fan-out — an accepted tradeoff to unify the blocking and streaming code paths; lower `batch_size` for finer-grained parallelism. The initiating thread's self-drain is the correctness floor: a consumer that provides NO external drain threads still gets correct, single-threaded results, and extra drainers only add parallelism. Cancellation, `max_results`, and result ordering are preserved across both paths; an exception thrown mid-scan is caught and surfaces as `VXCORE_ERR_UNKNOWN`. This mirrors the vxcore/VNote ownership split used by sync: vxcore emits per-file search work as facts, VNote owns the drain policy. No vxcore-owned threads remain, the former `BS::thread_pool` search pool having been removed. --- ## Incremental Update (Windows x64) VNote can update itself by downloading only what changed. A release publishes a **manifest** (`` for every file in the bundle) plus an optional **delta ZIP** containing only the files that changed since the previous stable release. The client diffs manifests, downloads the delta chain, stages files inside the install directory, swaps them in under a durable journal **at exit**, and restarts. **Scope: Windows x64 only** (`win64` Qt6 and `win64-windows7` Qt5 variants). Everywhere else the menu item opens the releases page. ### Ownership map | Layer | Unit | Responsibility | |---|---|---| | Pure value type | [`UpdateManifest`](src/core/updatemanifest.h) | parse/serialize, diff, `expectedChanged`, chain resolution + caps, base-identity validation, path normalization | | Pure | [`ZipExtractor`](src/core/zipextractor.h) | miniz-backed archive validation (before any byte is written) + containment-checked extraction | | Pure, pre-Qt | [`UpdateLease`](src/core/updatelease.h) | machine-wide, cross-session mutual exclusion | | Pure, post-teardown | [`UpdateInstaller`](src/core/updateinstaller.h) | journal, swap, `ReplaceExecutable`, rollback, crash recovery, probes, WebEngine reaping | | Service | [`UpdateService`](src/core/services/updateservice.h) | eligibility, network, planning, download, staging, `pending.json` | | Controller | [`UpdateController`](src/controllers/updatecontroller.h) | ALL policy: throttle, skipped version, prompts, notifications | | View | [`UpdateDialog`](src/widgets/dialogs/updatedialog.h) | version, notes, progress, Update / Skip / Later / Restart Now | | View | [`NotificationPopup2`](src/widgets/notificationpopup2.h) | the startup surface: Update / Check Release, then an in-place progress bar, then Restart / Retry | `UpdateController` routes transfer signals on an explicit `TransferSurface` (`None` / `Dialog` / `Notification`) claimed **only for a `startDownload()` call the service accepted** (`UpdateService::DownloadStart` = `Started` / `Busy` / `NoPlan` / `Stale`), **not** on the `m_dialog` pointer — a startup notification can coexist with an open non-modal `UpdateDialog`, and `m_dialog` / `m_manualCheck` describe the last *check*, not the current *transfer*. `Busy` and `Stale` start nothing and emit nothing, so claiming a surface before the call would strand it forever. Every download request also passes the version it was OFFERED, so a button that outlived its check is refused as `Stale` rather than silently downloading a different plan; `startCheck()` additionally dismisses the tracked offer notification, since a new check invalidates the plan its actions would start. `onFailed` with `TransferSurface::None` is a CHECK failure and keeps the old rules (silent `qWarning` on the startup path, message box on the manual path). `UpdateService` deliberately does NOT depend on `ConfigMgr2`: `core_configs` links `core_services`, so the reverse dependency would be circular. The installed version is injected and every config-dependent decision lives in `UpdateController`. ### Manifest contract Two artifacts per release variant, both produced by `scripts/gen-update-package.ps1`: - **In-package** `manifest.json` at the install root, inside the ZIP. - **Release asset** `VNote--.manifest.json` — the same object plus `fullPackage` and, when built, `delta { baseVersion, asset, size, sha256 }`. Rules: - `files[]` paths are forward-slash, install-root-relative, and **exclude `manifest.json` itself**. - Path comparison is case-insensitive (Windows); stored casing is preserved. - `channel` is `"stable"` or `"continuous"`. **Only `channel == "stable"` is eligible as a delta base.** CI derives it from the real release predicate (`refs/heads/master` + a `[Release]` head commit), never from "was this a tag build" — this repo cuts releases from a master push and creates the tag afterwards. - Deletions are **derived**, never stored. - Asset URLs are deterministic **and per-source** (`/v/VNote--.manifest.json`): - GitHub — `https://github.com/vnotex/vnote/releases/download` - Gitee — `https://gitee.com/vnotex/vnote/releases/download` ### Release source (GitHub or Gitee) `CoreConfig::updateSource` (`"github"` | `"gitee"`, default `github`, Settings › General) selects which forge the updater talks to. `UpdateController` **pushes** it into `UpdateService::setSource()`; the service never reads config (see the ownership note above). A change while a check or download is in flight is ignored and logged, so one plan can never mix manifests and archives from two origins. | | GitHub | Gitee | |---|---|---| | latest-release API | `https://api.github.com/repos/vnotex/vnote/releases/latest` | `https://gitee.com/api/v5/repos/vnotex/vnote/releases/latest` | | `Accept` header | `application/vnd.github+json, …` | `application/json, …` | | release page | the API's `html_url` | synthesized `https://gitee.com/vnotex/vnote/releases/tag/v` (Gitee's JSON has no `html_url`; the pattern is verified against the live v4.3.0 page, and note the `tag/` segment the asset download path does **not** have) | | host allowlist | exact `api.github.com`, `github.com`, `codeload.github.com` + suffix `.githubusercontent.com` | exact `gitee.com` + suffix `.gitee.com` (covers `foruda.gitee.com`) | The allowlists are **disjoint and source-scoped**: a client on one source must never follow a redirect to the other's hosts. Everything else about the transport is unchanged — manually followed redirects, at most `c_maxRedirects` hops, no HTTPS→HTTP downgrade, and signature verification over the bytes as received with the compiled-in Ed25519 key. That last property is what makes downloading from a mirror safe; do not weaken it for Gitee. `gitee-mirror.yml` mirrors the small update assets (`*.manifest.json`, `*.manifest.json.minisig`, `*.delta.zip`) automatically; the ~158 MB platform ZIPs stay a manual upload. Gitee also keeps only the two most recent releases, so a delta base older than that is simply unavailable there and the client falls back to the full package. ### Ineligibility, and the check-only degradation `checkEligibility()` gates in this order: Windows → 64-bit → install dir valid → **Microsoft Store** → Program Files → writable → same volume → trusted keys → rename probe. The Store gate (`UpdateInstaller::isMicrosoftStoreInstall()`, a dynamic `GetCurrentPackageFullName` probe so the Windows 7 variant still loads) must stay **before** the Program Files check: MSIX installs live under `C:\Program Files\WindowsApps`, and the Program Files reason tells the user to "use the installer package", which does not exist for a Store install. Separately, when the selected source publishes the release but **the manifest itself 404s**, the check degrades to check-only rather than failing: `eligible = false`, `updateAvailable = true`, and an `ineligibleReason` pointing at the release page. This absence is inferred **only** from the manifest fetch. Once manifest bytes have been received, any signature problem — including a 404 on the `.minisig` — remains a **hard failure**. `testMissingSignatureIsRefused()` pins that fail-closed property. ### Staging layout ``` /.vnote-update.lease exclusive sentinel (SIBLING of the directory below) /.vnote-update/download/ downloaded archives /.vnote-update/staged/ extracted new files, mirroring the install layout /.vnote-update/pending.json the plan /.vnote-update/journal.json durable write-ahead journal /.vnote-update/result.json apply outcome, consumed at the next launch /.vnote-old// backups + RECOVERY.txt ``` There is deliberately **no lock file inside `.vnote-update/`**: the lease sentinel is a sibling, so committing can delete that whole directory without a lock-vs-directory ordering race. On commit the staging root is removed and `result.json` is re-written afterwards, so it is the only survivor for the next launch to consume. ### Lease protocol (what it does and does not guarantee) - **Every launch** acquires the lease as the **first executable statement of `main()`**, before `Logger::installEarly()`, and holds it across `recoverInterrupted()` **and** `guard.tryRun()`. On timeout it shows a Win32 `MessageBoxW` and exits — never falls through to initialization. - **Guaranteed:** no process reaches VNote's own initialization (vxcore context, services, config, windows) while another is applying an update. - **NOT guaranteed (accepted residual risk 5):** the Windows loader resolves and maps this executable's static imports **before `main()` runs**, so a launch that races an in-progress apply may already have mapped a mixed old/new DLL set, or fail in the loader outright. No in-process lease can close that window — only the external-helper/bootstrap follow-up can. Do not write comments or docs claiming the lease prevents module mapping. - **Release timing depends on the guard outcome:** - `Primary` → released immediately after `tryRun()`. - `Secondary` / `BusyUnreachable` → **held until teardown is complete**, released as the last statement of `main()`. A rejected starter has already mapped those modules; releasing earlier would let an applier swap files still mapped into it. - `SingleInstanceGuard::tryRun()` is **fail-closed**: its historical "lock held, IPC unreachable → proceed anyway" branch now returns `BusyUnreachable` and the caller exits. - **The applying primary** re-acquires the lease after `app.exec()` returns, **while its guard is still held**, and keeps it until the replacement process has been spawned. No ABBA deadlock: a starter holding the lease never blocks on the guard (`QLockFile::tryLock(0)` is non-blocking and the IPC connect is bounded to 200 ms), so the applier's wait on the lease always terminates. ### Journal invariants - Every operation — add, replace, **delete**, path-type-conflict removal, `ReplaceExecutable` — has a durable journal entry **before** the corresponding move. - Ordinary operations move through four durable checkpoints (`Intent → dirs recorded+created → backup → BackedUp → move → Done`), so a kill at any point leaves the journal describing exactly what happened. - **Every checkpoint write is checked.** A `saveJournal` failure after a mutation stops the transaction and marks it `MANUAL_RECOVERY`; it must never continue into the next irreversible rename with a stale journal. The only unchecked writes are the best-effort final ones inside terminal `MANUAL_RECOVERY` paths. - **Rollback is replay-safe.** `rollback()` can only journal `Reverted` *after* the filesystem work returns, so a crash in between replays `performReverse()` against a half-reversed tree. For `Replace`/`Delete`/`ConflictRemove` the deciding fact is **the backup's existence, not the journal state** (`backup exists ⇔ restore not yet done`); re-running the `Done` path unconditionally would move the freshly restored original into staging and find no backup left to put back, deleting a production file. - **Directories created by an operation are recorded in that operation's entry**, so rollback removes them bottom-up when empty. - **Deletions are blocking**: a failed deletion rolls the transaction back, because a stale DLL that survived would also vanish from the new manifest and could never be cleaned up. - Manifest-commit failure is a transaction failure and rolls everything back. - **`RECOVERY.txt` is mandatory**: if it cannot be written the apply aborts *before* the first mutation, because it is the only mitigation for residual risks 1 and 4. It lists the paths this transaction **adds**, since overlaying the backup cannot remove those. - After a successful rollback the terminal journal is copied beside the backups and the live `journal.json` is **removed**, so the staged tree stays retryable instead of making `applyPending()` re-enter recovery forever. - `recoverInterrupted()` runs at the very top of `main()` and is idempotent and re-entrant — a crash *during* recovery must be safe. ### `ReplaceExecutable`: two renames, not one **A running executable is mapped as an IMAGE section, and Windows refuses to unlink the name of such a file.** Measured on Windows 11 24H2 / NTFS against a real PE: | target state | `FileRenameInfoEx` POSIX\|REPLACE | `FileRenameInfoEx` REPLACE only | `MoveFileExW` (aside) | |---|---|---|---| | no open handle | OK | OK | OK | | open handle, no section | OK | `ERROR_ACCESS_DENIED` | OK | | data section mapped | OK | `ERROR_ACCESS_DENIED` | OK | | **IMAGE section mapped** | **`ERROR_ACCESS_DENIED`** | `ERROR_ACCESS_DENIED` | **OK** | So the swap is two renames: 1. canonical `vnote.exe` → `/vnote.exe` (allowed on a mapped image); 2. `staged/vnote.exe` → canonical (the destination no longer exists). POSIX semantics is still preferred for both (strictly more permissive when any handle is open), with a `MoveFileExW` fallback taken **only** when `SetFileInformationByHandle` reports the info class itself is unimplemented — never on an `ERROR_ACCESS_DENIED`. That fallback is what keeps the Windows 7 / 8.1 `win64-windows7` variant eligible. The capability probe exercises exactly this sequence against a SEC_IMAGE-mapped copy of a real PE, at planning time **and** again in apply preflight. ### Accepted residual risks 1. **Mixed binary set after an interrupted apply.** The journal plus the two-rename sequence normally let recovery run on the next launch. But if the Windows loader cannot start a partially-swapped DLL set (e.g. a new `Qt6Core.dll` beside an old `Qt6Gui.dll`), the process dies before `main()` and recovery never runs. Mitigation: `RECOVERY.txt` written into `.vnote-old//` at transaction start; the backup is retained until the next successful launch. Eliminating this requires the external-helper follow-up. 2. **Handle contention**, in three honest tiers: detected during preflight or the WebEngine wait → **abort before any mutation**; detected mid-transaction → **rollback**; contention also blocks the rollback → the install is left mixed with journal and backups intact and degenerates into risk 1. Rare, but real: the design does **not** claim "never a broken install". 3. **Hosts without a usable rename sequence.** Probed by actual API result (never by OS version) at planning time and re-probed in apply preflight; such installs are marked ineligible and offered the download page. 4. **The canonical executable does not exist between the two renames.** One syscall wide, journaled as `ReplaceExecutable` state `BackedUp`, with the complete old image already in `.vnote-old//`. A machine death inside that window leaves no `vnote.exe`, so recovery cannot run and the user must restore from the backup using `RECOVERY.txt`. This is strictly weaker than a single atomic operation would be, and it is unavoidable on Windows (see the table above). 5. **A concurrent launch can map modules before its lease check.** The Windows loader resolves static imports before `main()`, so the lease cannot stop a process started during an apply from mapping a mixed DLL set. See "Lease protocol" above. Only the external-helper/bootstrap follow-up removes this. ### Forbidden patterns - **Never** touch the install tree outside `UpdateInstaller`. - **Never** apply before `vxcore_context_destroy()`; the apply runs only in the post-scope block of `main()`, after every service, `ConfigMgr2`, `Application` and the guard are gone. - **Never** use `qApp`, a service, a widget, `QtConcurrent`, the network, or `QCoreApplication::applicationFilePath()` after that scope exit. Use `UpdateInstaller::exePathFromModulePath()` and re-read the plan from `pending.json`. - **Never** rely on `SingleInstanceGuard` alone for update serialization (its pre-tri-state behavior was fail-open). - **Never** route `vnote.exe` through the generic swap or the generic reverse rollback; it has its own state machine for the reasons in the table above. - **Never** release the update lease before the replacement process has been spawned, and remember `exit(0)` does not unwind C++ objects — release it explicitly. Full design rationale, the recovery state table, and the manual E2E checklist live in `.kilo/plans/1785337074532-incremental-update-plan.md`. --- ## Code Style Guidelines ### Standards - **C++14** standard - **Qt 5/6** framework - CMake with `CMAKE_AUTOMOC`, `CMAKE_AUTOUIC`, `CMAKE_AUTORCC` enabled ### Formatting - 2-space indentation - 100 character line limit - Pointer alignment right: `int *ptr`, not `int* ptr` - Use provided `.clang-format` (auto-applied via pre-commit hook) ### Naming Conventions | Element | Convention | Example | |---------|------------|---------| | Classes | CamelCase | `ConfigMgr`, `MainWindow` | | Methods | camelCase | `getInst()`, `initLoad()` | | Parameters | `p_` prefix | `p_parent`, `p_config` | | Members | `m_` prefix | `m_themeMgr`, `m_config` | | Constants | `c_` prefix | `c_orgName`, `c_appName` | | Getters | `get` prefix | `getThemeMgr()`, `getName()` | ### Include Order ```cpp #include "ownheader.h" // Own header first #include // Qt includes #include #include "localheader.h" // Local includes #include #include using namespace vnotex; // Namespace declaration in .cpp ``` ### Header Guards ```cpp #ifndef CLASSNAME_H #define CLASSNAME_H // ... #endif // CLASSNAME_H ``` ### Namespaces VNote uses a single `vnotex` namespace. Services that wrap the vxcore C library use the `CoreService` suffix to distinguish them from higher-level wrapper services: | Class Pattern | Purpose | Examples | |---------------|---------|----------| | `XXXCoreService` | Low-level services that wrap the vxcore C library (hold `VxCoreContextHandle`) | `ConfigCoreService`, `NotebookCoreService`, `BufferCoreService`, `SearchCoreService`, `FileTypeCoreService` | | Other classes | Everything else: UI, controllers, models, hook-aware wrapper services | `BufferService` (hook wrapper), `HookManager`, `TemplateService`, `ConfigMgr2`, controllers, widgets | **Rules:** - `using namespace vnotex;` in `.cpp` files only, never in headers - Forward declarations preferred in headers --- ## Common Patterns ### Noncopyable Pattern ```cpp // src/core/noncopyable.h class Noncopyable { protected: Noncopyable() = default; virtual ~Noncopyable() = default; Noncopyable(const Noncopyable &) = delete; Noncopyable &operator=(const Noncopyable &) = delete; }; // Usage: private inheritance class MyClass : public QObject, private Noncopyable { // ... }; ``` ### Deprecation Macro Use `VNOTEX_DEPRECATED(msg)` from `src/core/global.h` to mark legacy classes, structs, and functions. It expands to `[[deprecated(msg)]]` on C++14+ (MSVC and GCC/Clang), or is a no-op on older compilers. ```cpp #include // Deprecate a class — place between 'class' keyword and class name class VNOTEX_DEPRECATED("Use MainWindow2 with ServiceLocator pattern instead") MainWindow : public QMainWindow { // ... }; // Deprecate a struct struct VNOTEX_DEPRECATED("Use FileOpenSettings instead") FileOpenParameters { // ... }; // Deprecate a function VNOTEX_DEPRECATED("Use newMethod() instead") void oldMethod(); ``` **Rules:** - Always include a migration message explaining what to use instead - For type deprecation, place the macro **between** the `class`/`struct` keyword and the type name - Requires `#include ` ### Exception Handling ```cpp // src/core/exception.h class Exception : virtual public std::runtime_error { public: enum class Type { InvalidPath, FailToCreateDir, FailToWriteFile, FailToReadFile, FailToRenameFile, /* ... */ }; [[noreturn]] static void throwOne(Type p_type, const QString &p_what) { qCritical() << typeToString(p_type) << p_what; throw Exception(p_type, p_what); } }; // Usage Exception::throwOne(Exception::Type::FailToReadFile, "Cannot read config"); ``` ### Signal/Slot Connections ```cpp // Preferred: new Qt5 syntax connect(m_taskMgr, &TaskMgr::taskOutputRequested, this, &VNoteX::showOutputRequested); // With overloaded methods connect(this, &VNoteX::openNodeRequested, m_bufferMgr, QOverload &>::of(&BufferMgr::open)); ``` ### Memory Management ```cpp // Use Qt smart pointers QScopedPointer m_config; QSharedPointer task; // QObject parent-child for automatic cleanup m_themeMgr = new ThemeMgr(this); // 'this' takes ownership ``` --- ## Widget Construction Pattern The migration off the legacy singleton architecture (`VNoteX::getInst()`, `ConfigMgr::getInst()`, the legacy `MainWindow`/`Buffer`) is complete; those types have been removed. All widgets, controllers, models, and views take their dependencies via constructor injection with `ServiceLocator&`. Follow this pattern for new code: ```cpp // Receives dependencies via constructor class MyWidget : public QWidget { Q_OBJECT public: explicit MyWidget(ServiceLocator &p_services, QWidget *p_parent = nullptr); private: ServiceLocator &m_services; void doSomething() { auto &config = m_services.get(); auto ¬ebooks = m_services.get(); } }; ``` ### Checklist for a new widget - [ ] Add `ServiceLocator &p_services` as the first constructor parameter - [ ] Store reference: `ServiceLocator &m_services` - [ ] Resolve dependencies via `m_services.get()` — never global state - [ ] Have the parent widget pass its `ServiceLocator&` down - [ ] Register the file in the relevant `CMakeLists.txt` - [ ] Write a unit test with mock services --- ## Logging Use Qt logging macros: ```cpp qDebug() << "Debug message"; qInfo() << "Info message"; qWarning() << "Warning message"; qCritical() << "Critical error"; ``` ## Code Formatting The pre-commit hook automatically formats staged C++ files using clang-format. **Manual formatting:** ```bash clang-format -i src/core/myfile.cpp ``` **Excluded from formatting:** `libs/` directory (third-party code) --- ## Shared JSON Keys (SSOT) Cross-boundary JSON keys (vxcore↔Qt) live in ``. See [`libs/vxcore/AGENTS.md` § JSON Conventions](libs/vxcore/AGENTS.md#json-conventions) for the SSOT contract and the `test_json_key_drift` regression gate. --- ## Module Documentation Index Detailed knowledge for each module lives in its own AGENTS.md: | Module | File | Description | |--------|------|-------------| | Core & Services | [src/core/AGENTS.md](src/core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, adding services | | Controllers | [src/controllers/AGENTS.md](src/controllers/AGENTS.md) | Controller patterns, MVC rules for controllers | | Models | [src/models/AGENTS.md](src/models/AGENTS.md) | Qt Model/View data representations | | Views | [src/views/AGENTS.md](src/views/AGENTS.md) | View conventions, delegate patterns | | Widgets | [src/widgets/AGENTS.md](src/widgets/AGENTS.md) | Widget conventions, ViewArea2 framework | | GUI Services | [src/gui/AGENTS.md](src/gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities | | Utilities | [src/utils/AGENTS.md](src/utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 reference | | Testing | [tests/AGENTS.md](tests/AGENTS.md) | Test infrastructure, test mode, coverage | | vxcore (submodule) | [libs/vxcore/AGENTS.md](libs/vxcore/AGENTS.md) | C library: notebook/config/search backend | | vxcore Sync | [libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md) | Pluggable sync backend interface (ISyncBackend, SyncManager) | | vtextedit (submodule) | [libs/vtextedit/AGENTS.md](libs/vtextedit/AGENTS.md) | Qt editor widget library |