Compare commits
373 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a08102188 | ||
|
|
1633f10752 | ||
|
|
c09f1b9c93 | ||
|
|
8cee321ebe | ||
|
|
f79cdac570 | ||
|
|
10aed871da | ||
|
|
30cde097d2 | ||
|
|
d4e66443bd | ||
|
|
1d5d573138 | ||
|
|
ed8a0b155b | ||
|
|
8b937012f9 | ||
|
|
6a80d01c34 | ||
|
|
ef55891cc2 | ||
|
|
9a2c025d07 | ||
|
|
9be7049bfd | ||
|
|
ff569bfba4 | ||
|
|
34fda19b62 | ||
|
|
b69cf8dd5b | ||
|
|
0234314945 | ||
|
|
6bf70eb635 | ||
|
|
78892c4637 | ||
|
|
6910347bf3 | ||
|
|
181ff6de8c | ||
|
|
07c47e68f9 | ||
|
|
05a6e61f11 | ||
|
|
7de2726f27 | ||
|
|
a45e44d0f3 | ||
|
|
14b1668ebe | ||
|
|
c5049e4332 | ||
|
|
c6e570d2e3 | ||
|
|
e93676bbb7 | ||
|
|
952e632622 | ||
|
|
641dc2c885 | ||
|
|
78f8469c98 | ||
|
|
1ec5f7c745 | ||
|
|
f616fe449b | ||
|
|
8ad23f72e1 | ||
|
|
41b531e899 | ||
|
|
03a667661e | ||
|
|
908994c253 | ||
|
|
af83d270fd | ||
|
|
24f4ee3272 | ||
|
|
bbb30d6b91 | ||
|
|
ba3f324a52 | ||
|
|
5240aa3b4e | ||
|
|
f80e571c78 | ||
|
|
df5c2a46e6 | ||
|
|
5090ed31bb | ||
|
|
0bad22eb57 | ||
|
|
05fc93677c | ||
|
|
bdb9fabfd0 | ||
|
|
4296977fe3 | ||
|
|
bb5296ba3d | ||
|
|
cfc62b1ce2 | ||
|
|
29bfde6a20 | ||
|
|
759af948b4 | ||
|
|
5fb1389d69 | ||
|
|
f5475f7773 | ||
|
|
1d93da3ade | ||
|
|
29e5dd8f7b | ||
|
|
18c6d18474 | ||
|
|
8828516d43 | ||
|
|
8df1f3043d | ||
|
|
95b89e331d | ||
|
|
2382305576 | ||
|
|
b9f90b0657 | ||
|
|
0f5ac35f67 | ||
|
|
6424509bb9 | ||
|
|
7614db62f2 | ||
|
|
42dc2f726f | ||
|
|
56c6cb56d6 | ||
|
|
37536e1974 | ||
|
|
9234ffcac2 | ||
|
|
198b7c993f | ||
|
|
ea54560e11 | ||
|
|
fbb51a0b4c | ||
|
|
c8eba73863 | ||
|
|
ae7ee4064f | ||
|
|
e3bfeba163 | ||
|
|
8c8a2bccfe | ||
|
|
42065d9843 | ||
|
|
6080500df0 | ||
|
|
fe1ff59879 | ||
|
|
fe939763cf | ||
|
|
e15aa76826 | ||
|
|
ebe85faf53 | ||
|
|
d0ce5ae165 | ||
|
|
780e33bff2 | ||
|
|
443d34b8b2 | ||
|
|
77fa49d5f5 | ||
|
|
a2094b57b7 | ||
|
|
59d272cf24 | ||
|
|
afc2ead14c | ||
|
|
80fb3dc6c9 | ||
|
|
181906cb17 | ||
|
|
1b00eaf461 | ||
|
|
9ad5ab822a | ||
|
|
c255c91a0b | ||
|
|
384aa22fbd | ||
|
|
093d331719 | ||
|
|
f9c5e70916 | ||
|
|
d08a08195c | ||
|
|
2a3589181e | ||
|
|
fb02644358 | ||
|
|
7c2e7a8990 | ||
|
|
e33f1b3106 | ||
|
|
815827b6e2 | ||
|
|
deb6998c97 | ||
|
|
1427035623 | ||
|
|
b7301568b8 | ||
|
|
0bf921dad1 | ||
|
|
563030982b | ||
|
|
41121ebf70 | ||
|
|
7516dfe48a | ||
|
|
8764b6f78e | ||
|
|
094f59eb7b | ||
|
|
62e1ab0e26 | ||
|
|
0135ce3f10 | ||
|
|
c752a52e28 | ||
|
|
ede82fc7a2 | ||
|
|
8c2432339a | ||
|
|
fbfd0f1cf3 | ||
|
|
2fb14f4c35 | ||
|
|
51e32dc832 | ||
|
|
a541c1b916 | ||
|
|
411c1947ae | ||
|
|
5bebd1c458 | ||
|
|
e2e9e81ee9 | ||
|
|
11c7e35519 | ||
|
|
b9bff9223e | ||
|
|
d204e95460 | ||
|
|
764c899b63 | ||
|
|
a3a9738034 | ||
|
|
ad28aed309 | ||
|
|
b5d462b3cd | ||
|
|
35e50b75fe | ||
|
|
018a0aff4f | ||
|
|
0ffacb3a83 | ||
|
|
46f82d201d | ||
|
|
c77d2af537 | ||
|
|
e662730fbc | ||
|
|
dc19b2d002 | ||
|
|
bb68ba78b6 | ||
|
|
4b375932f4 | ||
|
|
7d6ba934c6 | ||
|
|
aa4de6f155 | ||
|
|
7b62cd5e13 | ||
|
|
86e414d192 | ||
|
|
69bf1ca27b | ||
|
|
9ca05c1665 | ||
|
|
6fd0638d14 | ||
|
|
c62fb2227e | ||
|
|
29774c4f46 | ||
|
|
437c15a280 | ||
|
|
194cebdfe0 | ||
|
|
d7e1de65b6 | ||
|
|
38b336357f | ||
|
|
7f7272e09b | ||
|
|
8458a337bd | ||
|
|
d8e0524613 | ||
|
|
72e10d3b41 | ||
|
|
80e685cc33 | ||
|
|
d87d15982f | ||
|
|
b8b5515a7d | ||
|
|
e4afa7cfa5 | ||
|
|
42c3b93886 | ||
|
|
da3e9b9865 | ||
|
|
c6faf005b5 | ||
|
|
5374e66a80 | ||
|
|
3efa44df6d | ||
|
|
f1c2470a6e | ||
|
|
2e9f07bab4 | ||
|
|
39159b9b1e | ||
|
|
1be0c4b32f | ||
|
|
a9f38fb372 | ||
|
|
2a15ecb3e3 | ||
|
|
770fdee881 | ||
|
|
205f3641e3 | ||
|
|
56e5c98a84 | ||
|
|
73766ca471 | ||
|
|
0979c0664c | ||
|
|
bcbd9d3c5a | ||
|
|
69000f205a | ||
|
|
d8e280b9ee | ||
|
|
ce237039c9 | ||
|
|
6a3d730f0b | ||
|
|
c1cd894516 | ||
|
|
2d04335648 | ||
|
|
1c8207eeb5 | ||
|
|
fa9855fbf3 | ||
|
|
d03c780180 | ||
|
|
c903eb1e79 | ||
|
|
35d258c0ba | ||
|
|
720e2a9f60 | ||
|
|
b8b07a0310 | ||
|
|
f2bbcef7f0 | ||
|
|
2df076f834 | ||
|
|
7e119ee8f0 | ||
|
|
58f703e0a7 | ||
|
|
2172719f3e | ||
|
|
49008eb79c | ||
|
|
454181456d | ||
|
|
e07dab2310 | ||
|
|
6e97f3d4d3 | ||
|
|
a89416e9a5 | ||
|
|
5b275629be | ||
|
|
4a45616b16 | ||
|
|
ae529f64cd | ||
|
|
d337e2204a | ||
|
|
7110fef6f8 | ||
|
|
ec286e98d2 | ||
|
|
66b1a2b301 | ||
|
|
2a3ecf7270 | ||
|
|
3403c56394 | ||
|
|
2b368dc8f0 | ||
|
|
6d97335d68 | ||
|
|
3042d6180f | ||
|
|
12646d4e98 | ||
|
|
972d13e2cc | ||
|
|
86e88a3d43 | ||
|
|
f0fb7b7418 | ||
|
|
ba5464cc24 | ||
|
|
ec20c02784 | ||
|
|
5bb3fe0550 | ||
|
|
b4610ab9be | ||
|
|
7c231b08d7 | ||
|
|
7709ad1002 | ||
|
|
948b1501bf | ||
|
|
5dd3b98fec | ||
|
|
f0e32cfd5f | ||
|
|
806df2c586 | ||
|
|
3bed1f9235 | ||
|
|
07248fb868 | ||
|
|
8f948d4c41 | ||
|
|
cfb8e0aa4f | ||
|
|
fa9de47729 | ||
|
|
fe84ed91ac | ||
|
|
b5e0c9d890 | ||
|
|
5d9deb3028 | ||
|
|
600ddccff4 | ||
|
|
e3e7981d8a | ||
|
|
bf1feb204b | ||
|
|
2f4d52e53a | ||
|
|
2c9b897430 | ||
|
|
e1486959bb | ||
|
|
74dac301d3 | ||
|
|
2011acf1e2 | ||
|
|
50c46f9924 | ||
|
|
ecfc76af59 | ||
|
|
b2a94b482e | ||
|
|
425393b4a2 | ||
|
|
ac196bec58 | ||
|
|
4e3dfaad55 | ||
|
|
d5b2773436 | ||
|
|
695c8423f5 | ||
|
|
c7c53cafa2 | ||
|
|
6c29a01a69 | ||
|
|
7a72df1c2f | ||
|
|
5f87debecb | ||
|
|
57c61a1ddd | ||
|
|
08abcd5eff | ||
|
|
bb47cf875d | ||
|
|
0a36915903 | ||
|
|
df1e2913c0 | ||
|
|
ecf4df2d11 | ||
|
|
54595ab174 | ||
|
|
73742495b0 | ||
|
|
b93e25ddb1 | ||
|
|
5c5da8a670 | ||
|
|
3a7ce6bb5f | ||
|
|
ae6bd8ec73 | ||
|
|
c7f957775e | ||
|
|
9ea4c367bd | ||
|
|
55c0153692 | ||
|
|
67007e75ff | ||
|
|
57c6a4a2a4 | ||
|
|
663a7d9a3f | ||
|
|
0abf7b492a | ||
|
|
6cf77f9b94 | ||
|
|
2c1fe9f308 | ||
|
|
efd3cd4f27 | ||
|
|
3429474fc5 | ||
|
|
c6ce5f421b | ||
|
|
7c307e8baf | ||
|
|
9d447741f3 | ||
|
|
e9079df63c | ||
|
|
48fb6710e1 | ||
|
|
ae72a43a43 | ||
|
|
49935db529 | ||
|
|
cd4d3dece2 | ||
|
|
0ca0f4a6cf | ||
|
|
c2a0988368 | ||
|
|
ab123a1428 | ||
|
|
c1a5042fb9 | ||
|
|
9d331ffda8 | ||
|
|
41992506f1 | ||
|
|
761f232647 | ||
|
|
c09b25d3c5 | ||
|
|
4061d5e88e | ||
|
|
0983e4e288 | ||
|
|
247c3eefb9 | ||
|
|
28e2072f9b | ||
|
|
7adecaea60 | ||
|
|
2616f87185 | ||
|
|
d81c696742 | ||
|
|
678cc8f7ba | ||
|
|
35176d6eff | ||
|
|
42130e116d | ||
|
|
1040d95eec | ||
|
|
480876558a | ||
|
|
ce04a49bae | ||
|
|
3695b6f3fe | ||
|
|
7e50da7816 | ||
|
|
17a938f8cf | ||
|
|
3d2efcedeb | ||
|
|
c7b7b97022 | ||
|
|
0c8be62831 | ||
|
|
9b28f60be0 | ||
|
|
9b6a43cec7 | ||
|
|
c571b06293 | ||
|
|
14bf0dc32f | ||
|
|
238b4df55f | ||
|
|
881f860504 | ||
|
|
596d2b93bb | ||
|
|
16ce8b105c | ||
|
|
e7d74a7880 | ||
|
|
8cb5d0fd88 | ||
|
|
cebcd17db0 | ||
|
|
4276c1c3ac | ||
|
|
fa3cf3f7f3 | ||
|
|
8d761751e7 | ||
|
|
eabab20453 | ||
|
|
fdd6a64c72 | ||
|
|
2db83d0810 | ||
|
|
cdb303d98c | ||
|
|
fe2a35b09e | ||
|
|
816145ea0f | ||
|
|
5d3661995c | ||
|
|
c06b673e62 | ||
|
|
45e7416267 | ||
|
|
743b259fb5 | ||
|
|
154e6f68be | ||
|
|
053404afee | ||
|
|
459eae1334 | ||
|
|
22b366d6f9 | ||
|
|
9540a4e9dc | ||
|
|
de1cad448b | ||
|
|
1133b8ee6f | ||
|
|
ae4dbefe83 | ||
|
|
2f1ffeffcb | ||
|
|
a3a89ef483 | ||
|
|
6c533b7055 | ||
|
|
d54467cbfe | ||
|
|
359103b0d9 | ||
|
|
2302594c5c | ||
|
|
e226804947 | ||
|
|
165697f678 | ||
|
|
a6a8146975 | ||
|
|
ec03ffc7a9 | ||
|
|
beeecb1845 | ||
|
|
f7a54593ac | ||
|
|
6209b0baa5 | ||
|
|
8739605572 | ||
|
|
f18a309469 | ||
|
|
aa1d54f4c9 | ||
|
|
f816be5bbe | ||
|
|
101423c977 | ||
|
|
e66c81d896 | ||
|
|
e399d4c1fe | ||
|
|
63c5c5e6b1 | ||
|
|
d39fa1aa9d | ||
|
|
52aae9ed96 | ||
|
|
f72b7d3361 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.93
|
||||
AVERAGE_THRESHOLD=9.95
|
||||
|
||||
115
.github/scripts/configure-windows-authenticode.ps1
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
param(
|
||||
[string]$ConfigPath = "src-tauri/tauri.windows-signing.conf.json"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Read-FirstEnv {
|
||||
param([string[]]$Names)
|
||||
|
||||
foreach ($Name in $Names) {
|
||||
$Value = [Environment]::GetEnvironmentVariable($Name)
|
||||
if (-not [string]::IsNullOrWhiteSpace($Value)) {
|
||||
return $Value.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
throw "Set one of these environment variables: $($Names -join ', ')"
|
||||
}
|
||||
|
||||
function Read-OptionalEnv {
|
||||
param(
|
||||
[string[]]$Names,
|
||||
[string]$DefaultValue
|
||||
)
|
||||
|
||||
foreach ($Name in $Names) {
|
||||
$Value = [Environment]::GetEnvironmentVariable($Name)
|
||||
if (-not [string]::IsNullOrWhiteSpace($Value)) {
|
||||
return $Value.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
return $DefaultValue
|
||||
}
|
||||
|
||||
function Normalize-Thumbprint {
|
||||
param([string]$Thumbprint)
|
||||
|
||||
return ($Thumbprint -replace "\s", "").ToUpperInvariant()
|
||||
}
|
||||
|
||||
function Convert-CertificateSecretToBytes {
|
||||
param([string]$CertificateSecret)
|
||||
|
||||
$Base64Lines = $CertificateSecret -split "\r?\n" |
|
||||
Where-Object { $_ -notmatch "^-+BEGIN " -and $_ -notmatch "^-+END " }
|
||||
$CertificateBase64 = ($Base64Lines -join "") -replace "\s", ""
|
||||
|
||||
try {
|
||||
return [Convert]::FromBase64String($CertificateBase64)
|
||||
} catch {
|
||||
throw "Windows code-signing certificate must be base64-encoded PFX data."
|
||||
}
|
||||
}
|
||||
|
||||
$CertificateSecret = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE", "WINDOWS_CERTIFICATE")
|
||||
$CertificatePassword = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD", "WINDOWS_CERTIFICATE_PASSWORD")
|
||||
$ConfiguredThumbprint = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT", "WINDOWS_CERTIFICATE_THUMBPRINT") ""
|
||||
$DigestAlgorithm = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_DIGEST_ALGORITHM") "sha256"
|
||||
$TimestampUrl = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_TIMESTAMP_URL", "WINDOWS_TIMESTAMP_URL") "http://timestamp.digicert.com"
|
||||
|
||||
$TempRoot = Join-Path ([IO.Path]::GetTempPath()) "tolaria-windows-signing"
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
|
||||
$TempRoot = Join-Path $env:RUNNER_TEMP "tolaria-windows-signing"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $TempRoot | Out-Null
|
||||
|
||||
$PfxPath = Join-Path $TempRoot "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($PfxPath, (Convert-CertificateSecretToBytes $CertificateSecret))
|
||||
|
||||
$SecurePassword = ConvertTo-SecureString -String $CertificatePassword -Force -AsPlainText
|
||||
$ImportedCertificates = @(Import-PfxCertificate -FilePath $PfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $SecurePassword)
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue $PfxPath
|
||||
|
||||
$ImportedCertificate = $ImportedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1
|
||||
if ($null -eq $ImportedCertificate) {
|
||||
throw "The imported Windows code-signing certificate does not include a private key."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ConfiguredThumbprint)) {
|
||||
$CertificateThumbprint = Normalize-Thumbprint $ImportedCertificate.Thumbprint
|
||||
} else {
|
||||
$CertificateThumbprint = Normalize-Thumbprint $ConfiguredThumbprint
|
||||
}
|
||||
|
||||
$StoreCertificate = Get-ChildItem Cert:\CurrentUser\My |
|
||||
Where-Object { (Normalize-Thumbprint $_.Thumbprint) -eq $CertificateThumbprint } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $StoreCertificate) {
|
||||
throw "The requested Windows code-signing certificate thumbprint was not found in Cert:\CurrentUser\My."
|
||||
}
|
||||
|
||||
$Config = @{
|
||||
bundle = @{
|
||||
windows = @{
|
||||
certificateThumbprint = $CertificateThumbprint
|
||||
digestAlgorithm = $DigestAlgorithm
|
||||
timestampUrl = $TimestampUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ResolvedConfigPath = Resolve-Path -Path (Split-Path -Parent $ConfigPath) -ErrorAction SilentlyContinue
|
||||
if ($null -eq $ResolvedConfigPath) {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ConfigPath) | Out-Null
|
||||
}
|
||||
|
||||
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding utf8NoBOM
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) {
|
||||
"WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT=$CertificateThumbprint" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
|
||||
Write-Host "Prepared Windows Authenticode signing config at $ConfigPath."
|
||||
567
.github/workflows/release-build-artifacts.yml
vendored
Normal file
@@ -0,0 +1,567 @@
|
||||
name: Release build artifacts
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
macos_bundles:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
upload_macos_dmg:
|
||||
required: true
|
||||
type: boolean
|
||||
require_windows_authenticode:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
# The production Vite bundle can exceed Node's default ~2GB heap on
|
||||
# macOS arm64 runners while Tauri runs beforeBuildCommand.
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Bun (required for bundle-qmd.sh)
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Import Apple Developer certificate into keychain
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
|
||||
KEYCHAIN_PASSWORD="$(uuidgen)"
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate telemetry env
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
DISALLOWED_PLACEHOLDERS = {
|
||||
"",
|
||||
"-",
|
||||
"_",
|
||||
"false",
|
||||
"true",
|
||||
"null",
|
||||
"undefined",
|
||||
"none",
|
||||
"disabled",
|
||||
}
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1].strip()
|
||||
return value
|
||||
|
||||
def normalize_http_like(value: str) -> str:
|
||||
if "://" in value:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def normalize_hostname(hostname: str) -> str:
|
||||
normalized = hostname.strip().rstrip('.').lower()
|
||||
if normalized.startswith('[') and normalized.endswith(']'):
|
||||
normalized = normalized[1:-1]
|
||||
return normalized
|
||||
|
||||
def is_ip_address(hostname: str) -> bool:
|
||||
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
|
||||
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
|
||||
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
|
||||
|
||||
def is_allowed_hostname(hostname: str) -> bool:
|
||||
normalized = normalize_hostname(hostname)
|
||||
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
|
||||
return False
|
||||
if normalized == 'localhost':
|
||||
return True
|
||||
return '.' in normalized or is_ip_address(normalized)
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
for name in (
|
||||
"VITE_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"VITE_POSTHOG_KEY",
|
||||
"VITE_POSTHOG_HOST",
|
||||
)
|
||||
}
|
||||
errors = []
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if value.lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append(f"{name} must be set to a real value, not a placeholder")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
|
||||
|
||||
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("Telemetry env validation passed.")
|
||||
PY
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ inputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
MACOS_BUNDLES="${{ inputs.macos_bundles }}"
|
||||
if [ -n "$MACOS_BUNDLES" ]; then
|
||||
pnpm tauri build --target ${{ matrix.target }} --bundles "$MACOS_BUNDLES"
|
||||
else
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Upload .dmg
|
||||
if: ${{ inputs.upload_macos_dmg }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dmg-${{ matrix.arch }}
|
||||
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload updater artifacts (.tar.gz + .sig)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: updater-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
fcitx5-frontend-gtk3 \
|
||||
libfuse2 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ inputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
appimages=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
)
|
||||
installers=(
|
||||
"${appimages[@]}"
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#appimages[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Cache Tauri Windows tools
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~\AppData\Local\tauri
|
||||
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
|
||||
|
||||
- name: Prefetch Tauri NSIS toolchain
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prefetch-tauri-nsis.ps1
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached Windows bundle artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ inputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
id: windows-signing
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
|
||||
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
REQUIRE_WINDOWS_AUTHENTICODE: ${{ inputs.require_windows_authenticode }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
has_certificate=false
|
||||
has_password=false
|
||||
if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE" ] || [ -n "$WINDOWS_CERTIFICATE" ]; then
|
||||
has_certificate=true
|
||||
fi
|
||||
if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD" ] || [ -n "$WINDOWS_CERTIFICATE_PASSWORD" ]; then
|
||||
has_password=true
|
||||
fi
|
||||
|
||||
if [ "$has_certificate" != "$has_password" ]; then
|
||||
echo "::error::Windows Authenticode signing is partially configured. Set both certificate and password secrets, or remove both for unsigned alpha Windows artifacts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$has_certificate" = "true" ]; then
|
||||
echo "authenticode_available=true" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$REQUIRE_WINDOWS_AUTHENTICODE" = "true" ]; then
|
||||
echo "::error::WINDOWS_CODE_SIGNING_CERTIFICATE or WINDOWS_CERTIFICATE is required to Authenticode-sign Windows installers."
|
||||
exit 1
|
||||
else
|
||||
echo "::warning::Windows Authenticode certificate secrets are not configured. Building alpha Windows artifacts without Authenticode signatures; Tauri updater signatures are still required."
|
||||
echo "authenticode_available=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Prepare Windows Authenticode signing
|
||||
if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
|
||||
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
|
||||
WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT }}
|
||||
WINDOWS_CODE_SIGNING_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODE_SIGNING_TIMESTAMP_URL }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }}
|
||||
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }}
|
||||
run: ./.github/scripts/configure-windows-authenticode.ps1
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
shell: pwsh
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ inputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
if ("${{ steps.windows-signing.outputs.authenticode_available }}" -eq "true") {
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --config src-tauri/tauri.windows-signing.conf.json
|
||||
} else {
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
}
|
||||
|
||||
- name: Validate Windows Authenticode signatures
|
||||
if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$expectedThumbprint = $env:WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT
|
||||
if ([string]::IsNullOrWhiteSpace($expectedThumbprint)) {
|
||||
throw "WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT was not exported by the signing setup step."
|
||||
}
|
||||
$expectedThumbprint = ($expectedThumbprint -replace "\s", "").ToUpperInvariant()
|
||||
$paths = @()
|
||||
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "*.exe" -File -ErrorAction SilentlyContinue
|
||||
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" -Filter "*.exe" -File -ErrorAction SilentlyContinue
|
||||
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi" -Filter "*.msi" -File -ErrorAction SilentlyContinue
|
||||
$paths = @($paths | Sort-Object FullName -Unique)
|
||||
if ($paths.Count -eq 0) {
|
||||
throw "No Windows executable or installer artifacts found to verify."
|
||||
}
|
||||
foreach ($path in $paths) {
|
||||
$signature = Get-AuthenticodeSignature -FilePath $path.FullName
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "Invalid Authenticode signature for $($path.FullName): $($signature.Status)"
|
||||
}
|
||||
if ($null -eq $signature.SignerCertificate) {
|
||||
throw "Missing signer certificate for $($path.FullName)."
|
||||
}
|
||||
$actualThumbprint = ($signature.SignerCertificate.Thumbprint -replace "\s", "").ToUpperInvariant()
|
||||
if ($actualThumbprint -ne $expectedThumbprint) {
|
||||
throw "Unexpected signer thumbprint for $($path.FullName): $actualThumbprint"
|
||||
}
|
||||
Write-Host "Authenticode signature OK: $($path.FullName)"
|
||||
}
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
for installer in "${installers[@]}"; do
|
||||
if [[ "$(basename "$installer")" != *"${{ inputs.version }}"* ]]; then
|
||||
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
478
.github/workflows/release-stable.yml
vendored
@@ -6,13 +6,6 @@ on:
|
||||
- 'stable-v*'
|
||||
- 'v20*'
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
# The production Vite bundle can exceed Node's default ~2GB heap on
|
||||
# macOS arm64 runners while Tauri runs beforeBuildCommand.
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
concurrency:
|
||||
group: release-stable-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -63,471 +56,26 @@ jobs:
|
||||
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
|
||||
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build release bundles in parallel
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
# -------------------------------------------------------------
|
||||
# Phase 2: Build shared release artifacts
|
||||
# -------------------------------------------------------------
|
||||
build-artifacts:
|
||||
name: Build release artifacts
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun (required for bundle-qmd.sh)
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Import Apple Developer certificate into keychain
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
|
||||
KEYCHAIN_PASSWORD="$(uuidgen)"
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate telemetry env
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
DISALLOWED_PLACEHOLDERS = {
|
||||
"",
|
||||
"-",
|
||||
"_",
|
||||
"false",
|
||||
"true",
|
||||
"null",
|
||||
"undefined",
|
||||
"none",
|
||||
"disabled",
|
||||
}
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1].strip()
|
||||
return value
|
||||
|
||||
def normalize_http_like(value: str) -> str:
|
||||
if "://" in value:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def normalize_hostname(hostname: str) -> str:
|
||||
normalized = hostname.strip().rstrip('.').lower()
|
||||
if normalized.startswith('[') and normalized.endswith(']'):
|
||||
normalized = normalized[1:-1]
|
||||
return normalized
|
||||
|
||||
def is_ip_address(hostname: str) -> bool:
|
||||
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
|
||||
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
|
||||
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
|
||||
|
||||
def is_allowed_hostname(hostname: str) -> bool:
|
||||
normalized = normalize_hostname(hostname)
|
||||
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
|
||||
return False
|
||||
if normalized == 'localhost':
|
||||
return True
|
||||
return '.' in normalized or is_ip_address(normalized)
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
for name in (
|
||||
"VITE_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"VITE_POSTHOG_KEY",
|
||||
"VITE_POSTHOG_HOST",
|
||||
)
|
||||
}
|
||||
errors = []
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if value.lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append(f"{name} must be set to a real value, not a placeholder")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
|
||||
|
||||
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("Telemetry env validation passed.")
|
||||
PY
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
|
||||
- name: Upload .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dmg-${{ matrix.arch }}
|
||||
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload updater artifacts (.tar.gz + .sig)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: updater-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
needs: version
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
fcitx5-frontend-gtk3 \
|
||||
libfuse2 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
appimages=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
)
|
||||
installers=(
|
||||
"${appimages[@]}"
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#appimages[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Cache Tauri Windows tools
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~\AppData\Local\tauri
|
||||
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
|
||||
|
||||
- name: Prefetch Tauri NSIS toolchain
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prefetch-tauri-nsis.ps1
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached Windows bundle artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
for installer in "${installers[@]}"; do
|
||||
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
|
||||
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
uses: ./.github/workflows/release-build-artifacts.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
macos_bundles: ""
|
||||
upload_macos_dmg: true
|
||||
require_windows_authenticode: true
|
||||
secrets: inherit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (stable)
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
needs: [version, build-artifacts]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
473
.github/workflows/release.yml
vendored
@@ -10,13 +10,6 @@ on:
|
||||
- ".github/workflows/release.yml"
|
||||
- "site/**"
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
# The production Vite bundle can exceed Node's default ~2GB heap on
|
||||
# macOS arm64 runners while Tauri runs beforeBuildCommand.
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
concurrency:
|
||||
group: release-alpha-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -123,467 +116,27 @@ jobs:
|
||||
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
|
||||
echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
# tauri build handles signing automatically via env vars
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
# -------------------------------------------------------------
|
||||
# Phase 2: Build shared release artifacts
|
||||
# -------------------------------------------------------------
|
||||
build-artifacts:
|
||||
name: Build release artifacts
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
uses: ./.github/workflows/release-build-artifacts.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
macos_bundles: app
|
||||
upload_macos_dmg: false
|
||||
require_windows_authenticode: false
|
||||
secrets: inherit
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun (required for bundle-qmd.sh)
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Import Apple Developer certificate into keychain
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
|
||||
KEYCHAIN_PASSWORD="$(uuidgen)"
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate telemetry env
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
DISALLOWED_PLACEHOLDERS = {
|
||||
"",
|
||||
"-",
|
||||
"_",
|
||||
"false",
|
||||
"true",
|
||||
"null",
|
||||
"undefined",
|
||||
"none",
|
||||
"disabled",
|
||||
}
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1].strip()
|
||||
return value
|
||||
|
||||
def normalize_http_like(value: str) -> str:
|
||||
if "://" in value:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def normalize_hostname(hostname: str) -> str:
|
||||
normalized = hostname.strip().rstrip('.').lower()
|
||||
if normalized.startswith('[') and normalized.endswith(']'):
|
||||
normalized = normalized[1:-1]
|
||||
return normalized
|
||||
|
||||
def is_ip_address(hostname: str) -> bool:
|
||||
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
|
||||
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
|
||||
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
|
||||
|
||||
def is_allowed_hostname(hostname: str) -> bool:
|
||||
normalized = normalize_hostname(hostname)
|
||||
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
|
||||
return False
|
||||
if normalized == 'localhost':
|
||||
return True
|
||||
return '.' in normalized or is_ip_address(normalized)
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
for name in (
|
||||
"VITE_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"VITE_POSTHOG_KEY",
|
||||
"VITE_POSTHOG_HOST",
|
||||
)
|
||||
}
|
||||
errors = []
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if value.lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append(f"{name} must be set to a real value, not a placeholder")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
|
||||
|
||||
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("Telemetry env validation passed.")
|
||||
PY
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
# Alpha releases only need the notarized app bundle and updater tarball.
|
||||
# Skipping DMG packaging avoids fragile bundle_dmg.sh failures on macOS runners.
|
||||
pnpm tauri build --target ${{ matrix.target }} --bundles app
|
||||
|
||||
- name: Upload updater artifacts (.tar.gz + .sig)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: updater-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
needs: version
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
fcitx5-frontend-gtk3 \
|
||||
libfuse2 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
appimages=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
)
|
||||
installers=(
|
||||
"${appimages[@]}"
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#appimages[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Cache Tauri Windows tools
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~\AppData\Local\tauri
|
||||
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
|
||||
|
||||
- name: Prefetch Tauri NSIS toolchain
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prefetch-tauri-nsis.ps1
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached Windows bundle artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
for installer in "${installers[@]}"; do
|
||||
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
|
||||
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# No lipo/re-signing — use the per-arch artifacts directly
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (alpha)
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
needs: [version, build-artifacts]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -152,41 +152,44 @@ if [ "$APP_CHANGED" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
|
||||
# ── 0. Frontend lint ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📦 [0/5] TypeScript + Vite build..."
|
||||
pnpm exec tsc --noEmit
|
||||
# TypeScript is checked explicitly above; run Vite directly here to avoid
|
||||
# paying for the package build script's duplicate `tsc -b` pass.
|
||||
pnpm exec vite build
|
||||
echo "🔎 [0/6] Frontend lint..."
|
||||
pnpm lint
|
||||
echo " ✅ Lint OK"
|
||||
|
||||
# ── 1. TypeScript + Vite build ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📦 [1/6] TypeScript + Vite build..."
|
||||
pnpm build
|
||||
echo " ✅ Build OK"
|
||||
|
||||
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
# ── 2. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
echo ""
|
||||
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
|
||||
echo "📊 [2/6] Frontend tests + coverage (≥70%)..."
|
||||
pnpm test:coverage --silent
|
||||
echo " ✅ Frontend coverage OK"
|
||||
|
||||
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
# ── 3. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
echo ""
|
||||
if [ "$RUST_CHANGED" = true ]; then
|
||||
echo "🔧 [2/5] Clippy + rustfmt..."
|
||||
echo "🔧 [3/6] Clippy + rustfmt..."
|
||||
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
echo " ✅ Rust lint OK"
|
||||
else
|
||||
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [3/6] Rust lint — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
|
||||
# ── 4. Rust coverage (≥85% lines) ──────────────────────────────────────
|
||||
echo ""
|
||||
if [ "$RUST_CHANGED" = true ]; then
|
||||
LLVM_COV_FLAGS="--no-clean"
|
||||
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
|
||||
LLVM_COV_FLAGS=""
|
||||
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
echo "🦀 [4/6] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
else
|
||||
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
|
||||
echo "🦀 [4/6] Rust coverage (≥85%, incremental)..."
|
||||
fi
|
||||
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
@@ -199,24 +202,24 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
else
|
||||
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [4/6] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 4. Playwright core smoke lane (if any exist) ──────────────────────
|
||||
# ── 5. Playwright core smoke lane (if any exist) ──────────────────────
|
||||
echo ""
|
||||
SMOKE_FILES=$(find tests/smoke tests/integration -name '*.spec.ts' 2>/dev/null | head -1)
|
||||
if [ -n "$SMOKE_FILES" ]; then
|
||||
echo "🎭 [4/5] Playwright core smoke tests..."
|
||||
echo "🎭 [5/6] Playwright core smoke tests..."
|
||||
if ! pnpm playwright:smoke; then
|
||||
echo " ❌ Core smoke tests FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ Core smoke tests OK"
|
||||
else
|
||||
echo "⏭️ [4/5] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
|
||||
echo "⏭️ [5/6] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 5. CodeScene code health gate (ratchet) ──────────────────────────────
|
||||
# ── 6. CodeScene code health gate (ratchet) ──────────────────────────────
|
||||
# Thresholds live in .codescene-thresholds and only ever go UP (ratchet).
|
||||
# If remote scores improved, the hook updates the file and stops so the new
|
||||
# floor is committed with normal verified hooks before the next push.
|
||||
@@ -232,7 +235,7 @@ if [ -f "$THRESHOLDS_FILE" ]; then
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏥 [5/5] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
|
||||
echo "🏥 [6/6] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
|
||||
else
|
||||
|
||||
107
AGENTS.md
@@ -1,71 +1,22 @@
|
||||
# AGENTS.md — Tolaria App
|
||||
|
||||
> Quick links: [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
|
||||
## 1. Development Process
|
||||
|
||||
---
|
||||
|
||||
## 1. Task Workflow
|
||||
|
||||
### 1a. Pick up a task
|
||||
|
||||
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
|
||||
### Start working on a task
|
||||
|
||||
**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate.
|
||||
|
||||
- Read task description and all comments fully
|
||||
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
|
||||
- Check `docs/adr/` for relevant architecture decisions before structural choices
|
||||
- Add a comment: `🚀 Starting work on this task. [Brief description of approach]`
|
||||
|
||||
### 1b. Implement
|
||||
|
||||
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode. You don't need Pencil to use it – you can open it as a JSON file.
|
||||
|
||||
### 1c. When done
|
||||
|
||||
**Phase 1 — Playwright (only for core user flows):**
|
||||
|
||||
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
|
||||
|
||||
```bash
|
||||
pnpm dev --port 5201 &
|
||||
sleep 3
|
||||
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```
|
||||
|
||||
**Phase 2 — Native app QA:**
|
||||
|
||||
```bash
|
||||
pnpm tauri dev &
|
||||
sleep 10
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
```
|
||||
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
|
||||
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
|
||||
- What was implemented (a few lines covering logic and UX/UI)
|
||||
- QA: what was tested and how (Playwright / native screenshot / osascript)
|
||||
- Tests/coverage: commands run and final coverage result
|
||||
- CodeScene: before/after touched-file checks plus final Hotspot and Average scores after push
|
||||
- Codacy: MCP/CLI scan summary; confirm no new Critical/High findings
|
||||
- Localization: `pnpm l10n:translate` + `pnpm l10n:validate` result, or "no UI copy changes"
|
||||
- PostHog: event name(s) added, or why no event was needed
|
||||
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
|
||||
- ADRs: any new/updated ADRs (or "none")
|
||||
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
|
||||
|
||||
---
|
||||
|
||||
## 2. Development Process
|
||||
- Check `docs/ARCHITECTURE.md` and `docs/ABSTRACTIONS.md` for relevant structural information
|
||||
- For UI tasks: study app visual language and components first. Prioritize reusing existing components, assets, and variables over recreating them.
|
||||
- If working on a Todoist task, add a comment: `🚀 Starting work on this task. [Brief description of approach]`
|
||||
|
||||
### Commits & pushes
|
||||
|
||||
- Push directly to `main` — no PRs, no branches. Pre-push blocks non-`main` pushes.
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
- Pre-push hook runs full check suite (build + tests + core Playwright smoke + CodeScene)
|
||||
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify**
|
||||
|
||||
@@ -131,16 +82,46 @@ Coverage is a release gate, not a vanity metric:
|
||||
- For bug fixes, add a regression test when practical.
|
||||
- For new behavior, add targeted coverage close to the changed code; do not rely only on broad E2E coverage.
|
||||
|
||||
### UI and native QA
|
||||
|
||||
**Phase 1 — Playwright (only for core user flows):**
|
||||
|
||||
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
|
||||
|
||||
```bash
|
||||
pnpm dev --port 5201 &
|
||||
sleep 3
|
||||
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```
|
||||
|
||||
**Phase 2 — Native app QA:**
|
||||
|
||||
```bash
|
||||
pnpm tauri dev &
|
||||
sleep 10
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
```
|
||||
|
||||
Use computer-use/browser-control style interaction for native UI QA when available: click, hover, drag, select, scroll, and type the way a real user would with the mouse and trackpad. For every UI feature, test the primary mouse-driven path first, then verify any relevant keyboard shortcut or keyboard-first workflow still works. Tolaria is still a keyboard-first app, but QA must not assume users only interact by keyboard.
|
||||
|
||||
Use `osascript` for app focus, keyboard shortcuts, and keyboard-specific checks. **⚠️ WKWebView:** `osascript keystroke` can be blocked inside editor content — use computer use for native editor interaction when possible, and rely on Playwright for deterministic text-input coverage. Write result as Todoist comment (✅ or ❌).
|
||||
|
||||
### Release-readiness checklist
|
||||
|
||||
Before pushing or moving a task to In Review, verify and mention the result in the Todoist completion comment:
|
||||
Before pushing or moving a task to In Review, verify the release gates and add a **completion comment** to the Todoist task. The comment must include:
|
||||
|
||||
- CodeScene before/after checked for every touched/new scorable file; final Hotspot and Average pass `.codescene-thresholds`.
|
||||
- What was implemented (a few lines covering logic and UX/UI).
|
||||
- QA: what was tested and how (Playwright / native screenshot / osascript).
|
||||
- Tests/coverage: commands run and final coverage result.
|
||||
- CodeScene: before/after touched-file checks plus final Hotspot and Average scores after push; final scores must pass `.codescene-thresholds`.
|
||||
- Coverage commands passed (`pnpm test:coverage` and `cargo llvm-cov ... --fail-under-lines 85`) or the change is docs-only.
|
||||
- Codacy checked via MCP or `.codacy/cli.sh`; all new Critical/High issues fixed.
|
||||
- Localization checked: any user-facing copy lives in `src/lib/locales/en.json`, `pnpm l10n:translate` was run, and `pnpm l10n:validate` passes. If no copy changed, say “Localization: no UI copy changes”.
|
||||
- PostHog checked: meaningful new user actions/events are instrumented with safe metadata; noisy/minor changes explicitly say “PostHog: no event needed because …”.
|
||||
- Docs/ADRs checked under the rules below.
|
||||
- Codacy: MCP/CLI scan summary; confirm no new Critical/High findings.
|
||||
- Localization: any user-facing copy lives in `src/lib/locales/en.json`, `pnpm l10n:translate` was run, and `pnpm l10n:validate` passes. If no copy changed, say “Localization: no UI copy changes”.
|
||||
- PostHog: meaningful new user actions/events are instrumented with safe metadata; noisy/minor changes explicitly say “PostHog: no event needed because …”.
|
||||
- Refactoring: any files refactored to meet the CodeScene gate, or "none needed".
|
||||
- ADRs: any new/updated ADRs, or "none".
|
||||
- Docs: any updated docs (`ARCHITECTURE.md`, `ABSTRACTIONS.md`, etc.), or "none".
|
||||
- Demo vault dirt checked: `git status --short -- demo-vault demo-vault-v2` is empty unless fixture changes are intentional.
|
||||
|
||||
### ADRs & docs
|
||||
@@ -151,7 +132,7 @@ After any Tauri command, new component/hook, data model change, or new integrati
|
||||
|
||||
---
|
||||
|
||||
## 3. Product Rules
|
||||
## 2. Product Rules
|
||||
|
||||
### Demo vault hygiene (`demo-vault/`, `demo-vault-v2/`)
|
||||
|
||||
@@ -191,7 +172,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
||||
|
||||
---
|
||||
|
||||
## 4. Reference
|
||||
## 3. Reference
|
||||
|
||||
### macOS / Tauri gotchas
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ brew install --cask tolaria
|
||||
|
||||
### Download from releases
|
||||
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux. Windows installers are Authenticode-signed; company-managed devices may still require IT approval of the Tolaria publisher before first install.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ We currently support security fixes for:
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
|
||||
Please use GitHub's private vulnerability reporting flow for this repository.
|
||||
|
||||
Include as much of the following as you can:
|
||||
|
||||
|
||||
38
biome.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!src-tauri/gen/**",
|
||||
"!target/**",
|
||||
"!dist/**",
|
||||
"!node_modules/**"
|
||||
]
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["site/**/*.vue"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUnusedImports": "off",
|
||||
"noUnusedVariables": "off",
|
||||
"useHookAtTopLevel": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"useQwikValidLexicalScope": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ Git is a per-vault capability, not a prerequisite for the document model. A vaul
|
||||
| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state |
|
||||
| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` |
|
||||
|
||||
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
|
||||
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. The setup dialog supports "not now" for a one-time dismissal and "never for this vault" for a local per-vault opt-out from future automatic prompts. Features that depend on Git must check both the vault capability and the installation-local `git_enabled` setting instead of assuming every vault has `.git` or that Git chrome is globally visible.
|
||||
|
||||
Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead.
|
||||
|
||||
@@ -97,7 +97,7 @@ classDiagram
|
||||
+WorkspaceIdentity? workspace
|
||||
+Boolean trashed ⚠ legacy
|
||||
+Number? trashedAt ⚠ legacy
|
||||
+Record~string,string~ properties
|
||||
+Record~string,VaultPropertyValue~ properties
|
||||
}
|
||||
|
||||
class TypeDocument {
|
||||
@@ -149,7 +149,7 @@ interface VaultEntry {
|
||||
archived: boolean // Archived flag
|
||||
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
|
||||
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
properties: Record<string, VaultPropertyValue> // Scalar and scalar-array custom properties
|
||||
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
|
||||
}
|
||||
```
|
||||
@@ -179,6 +179,14 @@ Git-facing renderer code must pass an explicit repository path instead of assumi
|
||||
|
||||
`useGitFileWorkflows` is the renderer abstraction for note-scoped Git file actions. It translates active tabs, visible entries, and modified-file surfaces into the correct repository path for diff/history commands, deleted-note previews, queued editor diff requests, and discard refresh behavior.
|
||||
|
||||
### Tolaria Deep Links
|
||||
|
||||
Deep links identify existing vault items with `tolaria://<vault-slug>/<relative-path-with-extension>`. The slug is derived from the registered workspace alias, then label, then path basename; generated links append a stable short hash when two vaults share the same base slug. A manually typed ambiguous base slug is rejected instead of choosing the wrong vault.
|
||||
|
||||
The relative path is encoded per segment, preserving `/` as the separator while allowing spaces, Unicode, and reserved characters inside filenames. Decoding rejects `.`, `..`, encoded slashes, backslashes, empty segments, and any resolved path outside the target vault root. Links keep the file extension so Markdown, text, media, PDFs, and other vault files can all route through the same `VaultEntry` lookup.
|
||||
|
||||
Deep links are navigation-only. Opening one can focus Tolaria, switch to a registered vault, reload the index once, and open an existing item; it never creates missing files, imports external files, or silently falls back to another vault. v1 links are path-based, so renaming or moving a file changes the canonical link. macOS and Windows are the verified v1 desktop targets; Linux registration is best-effort until package-level QA covers the supported desktop environments.
|
||||
|
||||
### File kinds and binary previews
|
||||
|
||||
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
|
||||
@@ -189,7 +197,9 @@ Git-facing renderer code must pass an explicit repository path instead of assumi
|
||||
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
|
||||
| `binary` | Images, audio, video, PDFs, archives, other non-text files | Stays a normal vault file; previewable media and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. On Linux AppImage builds, `should_use_external_media_preview` can disable in-webview audio/video rendering so the same file blocks show filename/external-open fallback controls instead of triggering unstable WebKitGTK media playback. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
|
||||
Markdown note PDF export is not a stored file-kind transformation. `src/utils/notePdfExport.ts` temporarily marks the current webview for print and invokes Tauri's native webview print command, and `src/components/useEditorPdfExport.ts` ensures the rich rendered note is active before the system print/save-to-PDF dialog opens. The PDF reflects the current rendered editor DOM and leaves the vault file unchanged.
|
||||
|
||||
### Note Content Freshness
|
||||
|
||||
@@ -250,7 +260,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
|
||||
|
||||
**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
|
||||
**Instance schema/defaults**: Custom scalar/scalar-array properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
@@ -282,6 +292,8 @@ Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `Frontm
|
||||
- **List**: Multi-line ` - item` or inline `[item1, item2]`
|
||||
- **Null**: `owner:` (empty value)
|
||||
|
||||
Custom frontmatter fields with scalar values are exposed through `VaultEntry.properties`. Custom fields with scalar arrays are also exposed there, unless any array value contains a wikilink; wikilink-bearing fields belong to `VaultEntry.relationships`. Single-item scalar arrays continue to normalize to their scalar value for compatibility, while multi-item scalar arrays remain arrays so saved view filters can match exact elements.
|
||||
|
||||
### Custom Relationships
|
||||
|
||||
The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap:
|
||||
@@ -344,7 +356,7 @@ type SidebarSelection =
|
||||
|
||||
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
|
||||
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Inline folder creation carries an optional `FolderCreationParent` (`path` plus `rootPath`) through `App` to the `create_vault_folder` command, so new folders land under the selected folder or selected mounted vault root while preserving the active-vault path boundary. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
|
||||
- `src/components/sidebar/sidebarHooks.ts` owns the shared sidebar interaction primitives for menu positioning/dismissal and inline rename input behavior. Folder, Type, and saved View rows keep their domain-specific actions local, but use those primitives so right-click menus and rename fields have the same outside-click, Escape, focus, blur, and submit semantics.
|
||||
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
|
||||
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
|
||||
@@ -353,10 +365,12 @@ type SidebarSelection =
|
||||
|
||||
### Saved Views
|
||||
|
||||
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
|
||||
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. Filter conditions on scalar-array custom properties, such as `tags: [blues, chicago]`, evaluate `contains`, `any_of`, and related set operators against exact array elements rather than substrings. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
|
||||
|
||||
In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-owned `rootPath` and `workspace` provenance. `SidebarSelection.kind === 'view'` can include that `rootPath`, and view identity is `(rootPath, filename)` rather than filename alone. This lets two vaults both expose `views/focus.yml` without colliding in sidebar selection, note-list filtering, counts, sort/column persistence, edit, or delete flows. A saved View with `rootPath` filters only entries from its own workspace and persists changes through `save_view_cmd` / `delete_view_cmd` against that source vault.
|
||||
|
||||
`useAppViewActions()` owns the renderer-side saved View lifecycle: choosing the target workspace, preserving mounted-view identity, saving/deleting YAML definitions, reloading affected vault state, and exposing the available note-list fields for the create/edit dialog. `App.tsx` wires those callbacks into `Sidebar`, `NoteList`, `CreateViewDialog`, and command surfaces without duplicating the persistence rules.
|
||||
|
||||
The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar treats saved View rows like Type rows for direct customization: double-click starts inline rename, right-click opens edit/rename/icon-color/delete actions, and keyboard users can open that same menu from the focused row while command-palette actions remain responsible for saved View ordering.
|
||||
|
||||
### Neighborhood Mode
|
||||
@@ -374,9 +388,11 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
|
||||
|
||||
## Command Surface
|
||||
|
||||
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
|
||||
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, custom titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
|
||||
|
||||
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
|
||||
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the custom desktop titlebar menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
|
||||
|
||||
`useActionHistory` is the renderer-owned stack for reversible app-level actions. It records note-state actions only after persistence succeeds, replays one undo/redo at a time, and reveals the affected note before applying the reversal so editor pending-content flushes stay path-correct. Text editors and text inputs keep their native undo/redo history; app-level Undo/Redo shortcuts are handled only when focus is outside text-editing surfaces.
|
||||
|
||||
## File System Integration
|
||||
|
||||
@@ -504,15 +520,18 @@ interface PulseCommit {
|
||||
|
||||
### Auto-Sync
|
||||
|
||||
`useAutoSync` hook handles automatic git sync:
|
||||
`useAutoSync` hook handles automatic git sync across every active Git repository:
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
|
||||
- Pulls the active repository set concurrently on launch, focus, interval, and manual sync
|
||||
- Budgets automatic launch/focus/interval pulls per repository with a short cooldown so focus or low interval settings do not repeat network Git work immediately after a recent sync; manual sync bypasses this budget
|
||||
- Refreshes aggregate remote status after a pull, and avoids a separate startup status fetch when the initial pull will already refresh it
|
||||
- Pushes the active repository set during divergence recovery
|
||||
- Awaits the post-pull vault refreshes so toasts land after note-list state is fresh
|
||||
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
|
||||
- Tracks aggregate remote status (ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
|
||||
- `pullAndPush()`: pulls then auto-pushes each active repository for divergence recovery
|
||||
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
|
||||
|
||||
### External Vault Refresh
|
||||
@@ -545,7 +564,7 @@ External vault mutations are any disk writes Tolaria did not just perform throug
|
||||
- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false`
|
||||
- **Pulse view**: Activity feed when Pulse filter is selected
|
||||
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
|
||||
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
|
||||
- **Git status popup**: Click sync badge → shows aggregate ahead/behind and a Pull button for the active repository set
|
||||
- **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes
|
||||
|
||||
## BlockNote Customization
|
||||
@@ -573,6 +592,7 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
|
||||
|
||||
- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`.
|
||||
- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM.
|
||||
- Missing common grammars live in `src/utils/codeBlockLanguageCatalog.ts` and are registered lazily from direct `@shikijs/langs` imports by `src/components/codeBlockOptions.ts`; known aliases such as `ps1` and `vb` normalize to canonical picker values during Markdown import.
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript at creation time. Parsed unlabeled code blocks then run through Tolaria's lightweight language inference, while explicit fence languages and user dropdown choices still win.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep the dedicated code-block shell instead of inheriting the muted inline surface.
|
||||
|
||||
@@ -582,6 +602,7 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
|
||||
|
||||
- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes.
|
||||
- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note.
|
||||
- Double-clicking rendered math, or pressing `Enter`/`F2` when a math node is selected, restores the Markdown source and selects only the formula body for editing.
|
||||
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
|
||||
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
|
||||
|
||||
@@ -624,7 +645,8 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- `SingleEditorView` wraps the BlockNote surface in a narrow render-recovery boundary for BlockNote's transient `Block doesn't have id` node-view failure. The boundary retries the BlockNote view once, records `editor_render_recovered`, and marks the recovered error so the React root handler does not send that handled case back to Sentry. Other render errors still propagate through the normal root error path.
|
||||
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
|
||||
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
|
||||
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
|
||||
- `tauriEventCleanup.ts` owns safe Tauri event unlisten cleanup. Hooks and stream utilities route listener teardown through it so stale or duplicate native listener removals cannot surface as unhandled promise rejections during fast remounts, window teardown, or stream completion.
|
||||
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription used by native drop features.
|
||||
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
@@ -722,6 +744,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
|
||||
- **Anchored dropdowns**: Fixed-position property menus and note-list sort menus use `src/components/anchoredDropdown.ts` for anchor measurement, viewport clamping, scroll/resize repositioning, and optional max-height calculations. Property-specific filtering and keyboard navigation stay in `propertyDropdownUtils.ts`.
|
||||
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
|
||||
- **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
@@ -755,6 +778,8 @@ interface SearchResult {
|
||||
- Click result to open note in editor
|
||||
- Shows relevance score and snippet
|
||||
|
||||
The NoteList header search keeps its local title/snippet/property filtering for immediate scoped results, then augments the match set with `search_vault` hits from the visible workspace roots using the command's frontmatter-excluding search option. React stores only matching paths so body-only matches appear in the current list scope without a second content-read pass or rendering private matched text in note rows.
|
||||
|
||||
No indexing step required — search runs directly against the filesystem.
|
||||
|
||||
## Vault Management
|
||||
@@ -774,7 +799,7 @@ Mounted workspaces are loaded together by `useVaultLoader` for note-list, quick-
|
||||
|
||||
Per-vault settings stored locally and scoped by vault path:
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
|
||||
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, Git setup prompt preference, AI agent permission mode (`safe` / `power_user`)
|
||||
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
@@ -788,6 +813,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
|
||||
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini back to `AGENTS.md`
|
||||
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
|
||||
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched
|
||||
- Editing a usable `AGENTS.md`, including changing its frontmatter `type`, makes the file custom rather than broken; broken is reserved for missing, empty, frontmatter-only, unreadable, or exact replaceable managed templates/stubs
|
||||
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
|
||||
|
||||
Vault guidance is intentionally short and vault-specific. General Tolaria product behavior is delivered through the bundled agent docs resource instead:
|
||||
@@ -795,6 +821,15 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
|
||||
- `src-tauri/resources/agent-docs/AGENTS.md` orients agents to the generated docs bundle, while `index.md`, section bundles, `all.md`, `search-index.json`, and `pages/` provide fast local lookup
|
||||
- `get_agent_docs_path` exposes the resolved resource folder to the renderer, and `buildAgentSystemPrompt()` tells every app-managed CLI agent to read vault `AGENTS.md` first, then search the bundled docs for Tolaria behavior
|
||||
|
||||
### Action History
|
||||
|
||||
`useActionHistory` owns renderer-scoped app undo/redo state. It stores explicit action entries with labels plus undo/redo callbacks, suppresses nested recording during replay, and exposes the top labels to command-palette commands.
|
||||
|
||||
- Frontmatter mutations record history only after the write succeeds and only for non-silent user actions.
|
||||
- Entry state toggles such as archive, favorite, and organized record explicit before/after replay callbacks after persistence succeeds.
|
||||
- Text inputs, contenteditable surfaces, and editor-owned text history keep native undo/redo first; app-level history runs only when focus is outside text editing.
|
||||
- Irreversible destructive actions stay outside the stack and continue to use confirmation/destructive affordances.
|
||||
|
||||
### Getting Started / Onboarding
|
||||
|
||||
`useOnboarding` hook detects first launch:
|
||||
@@ -811,7 +846,8 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
|
||||
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
|
||||
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
|
||||
- Only shows after vault onboarding has already resolved to a ready state
|
||||
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
|
||||
- Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
|
||||
- The shared `useAiAgentsStatus` hook defers that command until after the first render and skips it when AI features are disabled or the current window cannot render AI status surfaces
|
||||
- Persists dismissal locally once the user continues
|
||||
|
||||
### Remote Git Operations
|
||||
@@ -820,7 +856,7 @@ Tolaria delegates remote auth to the user's system git setup:
|
||||
- `CloneVaultModal` captures a remote URL and local destination
|
||||
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
|
||||
- On macOS, system-git commands prefer the user's login-shell `git` and `PATH`, and `git_add_remote` preflights HTTPS remotes through `git credential fill` so Keychain can prompt/grant access before the first fetch or push
|
||||
- On Linux AppImage launches, every system-git command and MCP Node subprocess removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and system `node` bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries
|
||||
- On Linux AppImage launches, every system-git command and MCP runtime subprocess (Node.js or Bun) removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and the system MCP runtime bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries
|
||||
- On native Linux Wayland launches and Linux AppImage launches, startup environment safeguards set `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` unless the user already provided either variable, keeping WebKitGTK rendering crashes out of the app startup path while leaving native X11 launches unchanged.
|
||||
- On Linux AppImage launches, release packaging bundles the GTK3 fcitx immodule into the AppImage and startup environment safeguards write a cache-local `GTK_IM_MODULE_FILE` that points GTK at the mounted module whenever fcitx is configured. If the user has not explicitly chosen a GTK IM module, Tolaria also sets `GTK_IM_MODULE=fcitx`, allowing WebKitGTK editor input to reach fcitx5 on both Wayland and X11 fallback launches without relying on host GTK module cache paths.
|
||||
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
|
||||
@@ -832,6 +868,13 @@ Tolaria delegates remote auth to the user's system git setup:
|
||||
App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads legacy `com.laputa.app` on upgrade):
|
||||
|
||||
```typescript
|
||||
interface AiWorkspaceConversationSetting {
|
||||
archived: boolean | null
|
||||
id: string
|
||||
target_id: string | null
|
||||
title: string
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
auto_pull_interval_minutes: number | null
|
||||
autogit_enabled: boolean | null
|
||||
@@ -848,9 +891,11 @@ interface Settings {
|
||||
note_width_mode: 'normal' | 'wide' | null
|
||||
sidebar_type_pluralization_enabled: boolean | null // null = default true
|
||||
ai_features_enabled: boolean | null // null = default true
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
|
||||
git_enabled: boolean | null // null = default true
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | 'kiro' | null
|
||||
default_ai_target: string | null // "agent:codex" or "model:<provider>/<model>"
|
||||
ai_model_providers: AiModelProvider[] | null
|
||||
ai_workspace_conversations: AiWorkspaceConversationSetting[] | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
all_notes_show_pdfs: boolean | null // null = default false
|
||||
all_notes_show_images: boolean | null // null = default false
|
||||
@@ -858,7 +903,7 @@ interface Settings {
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -906,6 +951,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
|
||||
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The Windows job builds NSIS with Tauri updater signatures and uses Authenticode signing plus `Get-AuthenticodeSignature` verification when CI certificate secrets are configured; stable remains the strict channel for mandatory Authenticode enforcement. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. Changes to the shared artifact workflow are not ignored by the alpha trigger, so release-pipeline fixes produce a fresh alpha run. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Authenticode-signed Windows x64 installers plus Tauri-signed updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts selected non-Windows installers without replacing the page with a blank download navigation. Windows visitors see an explicit signed-installer action and managed-device approval guidance instead of an automatic download. Linux visitors default to the AppImage target while the page exposes RPM as a manual Linux package option when the stable release includes one. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -26,7 +26,9 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
| Property display order | Window size / position |
|
||||
| Per-note `_width` rich-editor width override | Default rich-editor note width |
|
||||
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
|
||||
| N/A | Whether this installation shows Git features |
|
||||
| Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility |
|
||||
| N/A | Per-vault Git setup prompt opt-out |
|
||||
| Type `_sidebar_label` overrides | Whether this installation auto-pluralizes type labels |
|
||||
| N/A | Registered workspace labels, aliases, mount state, and default new-note destination |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
@@ -108,6 +110,8 @@ Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.m
|
||||
|
||||
The registered vault list can act as a mounted-workspace set. `useVaultSwitcher` persists each workspace's installation-local identity (`label`, stable `alias`, color, mount flag) and the default destination for newly created notes in `~/.config/com.tolaria.app/vaults.json`. `useVaultLoader` scans every available mounted workspace and annotates each `VaultEntry` with provenance before React consumes the combined graph. The default workspace is the write target for new notes and Type documents; it is not the only active vault when multiple workspaces are enabled.
|
||||
|
||||
Vault item deep links use the registered vault list as their resolver namespace. `src/utils/deepLinks.ts` builds `tolaria://<vault-slug>/<relative-path-with-extension>` URLs from workspace aliases, labels, and paths, appending a short stable hash when generated slugs would collide. `useDeepLinks` validates incoming links, switches vaults when required, reloads the vault index once for recently changed files, and opens the matching `VaultEntry` through the normal note-selection path.
|
||||
|
||||
Saved Views participate in that mounted graph as source-scoped chrome. `useVaultLoader` loads view definitions from every mounted vault, annotates each `ViewFile` with its owning `rootPath` and workspace identity, and keeps sidebar selection/persistence keyed by `(rootPath, filename)` so same-named view files from different vaults stay independent.
|
||||
|
||||
Git surfaces resolve repository paths explicitly. `useGitRepositories` derives the active repository set from the mounted available workspaces, keeps separate selected repositories for Changes, Pulse/history, and manual commits, and exposes the combined modified-file count for status/commands. AutoGit checkpoints iterate that repository set, while manual commit, history, diff, and discard operations use the selected surface or the note's workspace provenance.
|
||||
@@ -130,6 +134,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
|
||||
| Frontend | React + TypeScript | React 19, TS 5.9 |
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Code block highlighting | @blocknote/code-block | 0.46.2 |
|
||||
| Additional code grammars | @shikijs/langs | 3.23.0 |
|
||||
| Diagram rendering | Mermaid | 11.14.0 |
|
||||
| Whiteboard rendering | tldraw | 4.5.10 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
@@ -140,7 +145,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
|
||||
| Backend language | Rust (edition 2021) | 1.77.2 |
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| Filesystem watcher | notify | 6.1 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini) | - |
|
||||
| AI (workspace) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini + Kiro) plus configured local/API model targets | - |
|
||||
| Search | Keyword (walkdir-based file scan) | - |
|
||||
| Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
@@ -159,13 +164,15 @@ flowchart TD
|
||||
NL["NoteList / PulseView\n(filtered list / activity)"]
|
||||
ED["Editor\n(BlockNote + diff + raw)"]
|
||||
IN["Right Panel\n(Inspector + TOC)"]
|
||||
AIP["AiPanel\n(selected CLI agent + tools)"]
|
||||
AIW["AiWorkspace\n(docked or native window)"]
|
||||
AIP["AiPanel\n(transcript + composer)"]
|
||||
SP["SearchPanel\n(keyword search)"]
|
||||
ST["StatusBar\n(vault picker + sync + version)"]
|
||||
CP["CommandPalette\n(Cmd+K launcher)"]
|
||||
|
||||
App --> WS & SB & NL & ED & SP & ST & CP
|
||||
ED --> IN & AIP
|
||||
App --> WS & SB & NL & ED & AIW & SP & ST & CP
|
||||
ED --> IN
|
||||
AIW --> AIP
|
||||
end
|
||||
|
||||
subgraph RB["Rust Backend"]
|
||||
@@ -179,7 +186,7 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph EXT["External Services"]
|
||||
CCLI["Claude / Codex / OpenCode / Pi / Gemini CLI\n(agent subprocesses)"]
|
||||
CCLI["Claude / Codex / OpenCode / Pi / Gemini / Kiro CLI\n(agent subprocesses)"]
|
||||
MCP["MCP Server\n(ws://9710, 9711)"]
|
||||
GCLI["git CLI\n(system executable)"]
|
||||
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
|
||||
@@ -206,7 +213,7 @@ flowchart TD
|
||||
│ │ OR │ │ OR │
|
||||
│ All │ Pulse View │ [Breadcrumb Bar] │ TOC │
|
||||
│ Changes│ │ │ OR │
|
||||
│ Pulse │ [Search] │ # My Note │ AI Agent │
|
||||
│ Pulse │ [Search] │ # My Note │ Properties │
|
||||
│ Inbox │ [Sort/Filt] │ │ │
|
||||
│ │ │ │ Context │
|
||||
│Projects│ Note 1 │ Content here... │ Messages │
|
||||
@@ -220,10 +227,13 @@ flowchart TD
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
|
||||
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Folder creation sends the selected folder's vault-relative path and mounted root to `create_vault_folder`, so a new folder is created under the focused parent instead of defaulting to the active vault root. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. In mounted multi-vault graphs, duplicate type names still render as one sidebar section, but the visibility picker becomes a workspace matrix and writes visibility to the specific vault's Type document, so hidden type definitions suppress only notes of that type from the same workspace.
|
||||
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block` plus lazy direct `@shikijs/langs` registrations for missing common grammars. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and opens the platform print/save-to-PDF dialog through the Tauri `print_current_webview` command, with `window.print()` as the browser fallback. The export reuses rendered BlockNote output so math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified.
|
||||
- **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat.
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
|
||||
|
||||
@@ -231,14 +241,18 @@ The main Tauri window derives its minimum width from the visible panes instead o
|
||||
|
||||
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
|
||||
|
||||
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
|
||||
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, and the Rust command fans per-agent CLI checks across Tokio's blocking pool. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
|
||||
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers the macOS Window submenu with Tauri's reserved `WINDOW_SUBMENU_ID` so NSApp can add system window-management and window-list items, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
|
||||
Desktop startup registers `tauri-plugin-deep-link` and `tauri-plugin-single-instance` before setup so `tolaria://` links can focus the existing main window and deliver URL events to the renderer. `tauri.conf.json` declares the `tolaria` scheme for bundled desktop builds; Windows and Linux also run `register_all()` as a runtime repair path, while macOS relies on bundle registration.
|
||||
|
||||
Linux and Windows use custom React-rendered window chrome instead of the native Tauri menu bar. `setup_custom_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu is macOS-only so Services/Hide/Quit and the reserved `WINDOW_SUBMENU_ID` keep behaving like normal NSApp menu items, while cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback from the renderer menu.
|
||||
On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
|
||||
Notes can be opened in separate Tauri windows for focused editing. Secondary windows boot the same `App` shell and load the same active workspace graph as the main window, but they start in the editor-only view mode with side panels collapsed.
|
||||
|
||||
The AI workspace can also open in a separate Tauri window through `openAiWorkspaceWindow()`. That window uses `?window=ai-workspace` to boot the lightweight `AiWorkspaceWindowApp` route, receives the active vault context through URL params, opts out of main-window size constraints and startup AI onboarding, and redocks by emitting `tolaria:ai-workspace-dock-requested` to the main window. Its webview is transparent so the rounded workspace shell defines the visible floating-window corners across desktop platforms.
|
||||
|
||||
**Triggers:**
|
||||
- `Cmd+Shift+Click` on any note in the note list or sidebar
|
||||
@@ -248,8 +262,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
**Architecture:**
|
||||
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- `main.tsx` always mounts `App`; `App` checks `isNoteWindow()` at startup, keeps normal vault/workspace loading active, and `useNoteWindowLifecycle` opens the requested note after the app graph is ready
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 1.5s low-end-safe idle debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
|
||||
- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
@@ -263,8 +276,8 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts` + `aiTargets.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-target selection, bundled-docs prompt injection, and the per-vault Safe / Power User permission mode shown in the panel header for coding agents
|
||||
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Codex, OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous permission-bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Codex, OpenCode, Pi, Gemini, and Kiro all launch from the active vault cwd with transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault.
|
||||
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
|
||||
|
||||
@@ -283,11 +296,11 @@ sequenceDiagram
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
|
||||
R->>R: pick adapter for claude_code, codex, opencode, or pi
|
||||
R->>R: pick adapter for claude_code, codex, opencode, pi, gemini, or kiro
|
||||
R->>C: spawn agent with MCP-enabled config
|
||||
|
||||
loop Normalized stream
|
||||
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, or Gemini JSONL events
|
||||
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, Gemini JSONL, or Kiro text events
|
||||
R-->>FE: emit("ai-agent-stream", event)
|
||||
alt TextDelta
|
||||
FE->>FE: accumulate response (revealed on Done)
|
||||
@@ -377,13 +390,13 @@ Tolaria can register itself as an MCP server in:
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
- `~/.config/opencode/opencode.json` (OpenCode, using its `mcp` config key)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini/OpenCode settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes a vault-neutral `type: "stdio"` entry for standard MCP clients, writes OpenCode's vault-neutral `type: "local"` entry, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Durable external MCP processes resolve active workspaces at tool-call time: explicit `VAULT_PATH`/`VAULT_PATHS` env still wins for app-owned and legacy launches, otherwise the Node server reads Tolaria's `vaults.json`, uses `active_vault` first, and includes every workspace not marked `mounted: false`. Vault context checks each active workspace root for `AGENTS.md` and includes those instructions in the returned context. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. Linux AppImage startup extracts the bundled `mcp-server/` to `~/.local/share/tolaria/mcp-server/` with a `.tolaria-version` marker, so durable external registrations use a stable path instead of the changing AppImage mount point. The `useMcpStatus` hook tracks whether Tolaria's durable MCP entry is connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini/OpenCode settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria resolves an MCP runtime (Node.js 18+ preferred, Bun 1+ as fallback) before writing config so external clients are not left pointing at a missing binary, writes a vault-neutral `type: "stdio"` entry for standard MCP clients, writes OpenCode's vault-neutral `type: "local"` entry, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Durable external MCP processes resolve active workspaces at tool-call time: explicit `VAULT_PATH`/`VAULT_PATHS` env still wins for app-owned and legacy launches, otherwise the MCP server reads Tolaria's `vaults.json`, uses `active_vault` first, and includes every workspace not marked `mounted: false`. Vault context checks each active workspace root for `AGENTS.md` and includes those instructions in the returned context. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. Linux AppImage startup extracts the bundled `mcp-server/` to `~/.local/share/tolaria/mcp-server/` with a `.tolaria-version` marker, so durable external registrations use a stable path instead of the changing AppImage mount point. The `useMcpStatus` hook tracks whether Tolaria's durable MCP entry is connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the runtime process instead of keeping it alive in the background.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph MCP["MCP Server (Node.js) — mounted-workspace scoped"]
|
||||
subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"]
|
||||
IDX["index.js"]
|
||||
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
|
||||
WSB["ws-bridge.js"]
|
||||
@@ -425,7 +438,7 @@ flowchart LR
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with `VAULT_PATH`/`VAULT_PATHS` env |
|
||||
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
|
||||
| `extract_mcp_server_to_stable_dir(app_version)` | On Linux AppImage launches, copies bundled MCP files to `~/.local/share/tolaria/mcp-server/` with version-gated replacement so external clients can keep a stable `index.js` path |
|
||||
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged or stable extracted `mcp-server/`, and writes Tolaria's vault-neutral entry to Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs on user request |
|
||||
| `register_mcp(vault_path)` | Resolves an MCP runtime (Node.js 18+ preferred, Bun 1+ fallback), resolves the packaged or stable extracted `mcp-server/`, and writes Tolaria's vault-neutral entry to Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs on user request |
|
||||
| `mcp_config_snippet(vault_path)` | Builds the exact vault-neutral `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
@@ -443,13 +456,15 @@ Search is keyword-based, using `walkdir` to scan all `.md` files in the vault di
|
||||
|
||||
The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score.
|
||||
|
||||
The note-list search field combines client-side scoped filtering with that same command: title, snippet, and visible-property matches resolve immediately, while backend body-content hits use `search_vault` with frontmatter excluded before adding matching paths for the currently visible workspace roots without displaying matched body text in the note row.
|
||||
|
||||
## Vault Cache System
|
||||
|
||||
The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning using git-based incremental updates.
|
||||
|
||||
### Cache File
|
||||
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v14 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
|
||||
|
||||
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
|
||||
|
||||
@@ -512,6 +527,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
|
||||
- `allNotes.noteListProperties`: Optional All Notes-only property chip override for the note list
|
||||
- `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection
|
||||
- `git_setup_preference`: `"never"` when the user has opted out of future automatic Git setup prompts for that vault
|
||||
|
||||
### Getting Started Vault
|
||||
|
||||
@@ -522,15 +538,15 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
|
||||
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
|
||||
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
When an opened folder is not yet a git repo, Tolaria can show a Git setup dialog with Initialize, Not now, and Never for this vault actions. The Never choice stores a local per-vault `git_setup_preference` so the automatic dialog does not return for that vault, while manual initialization remains reachable from Git commands when global Git features are enabled. Markdown scanning, note browsing, note editing, and search continue normally in plain folders. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, and Gemini CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
|
||||
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
|
||||
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. Once a user edits a usable `AGENTS.md`, including changing its frontmatter `type`, the status command treats it as custom guidance instead of broken; repair remains reserved for missing, empty, frontmatter-only, unreadable, or exact replaceable managed templates/stubs. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
|
||||
|
||||
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
|
||||
|
||||
@@ -657,7 +673,9 @@ flowchart TD
|
||||
|
||||
`useGitRemoteStatus` re-checks `git_remote_status` for the default repository, and `useCommitFlow` can resolve remote status for an explicit selected repository when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps that repository's flow local-only: the status bar shows a neutral `No remote` chip for the default repository, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
|
||||
|
||||
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
|
||||
If the current vault is not a Git repository, Tolaria treats Git as unavailable instead of degraded. With global Git features enabled, the status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup unless the user has chosen not to be prompted automatically for that vault. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
|
||||
|
||||
The installation-local `git_enabled` setting is a broader visibility switch. When it is `false`, Tolaria hides Git status-bar entries and Git command-palette actions completely, disables AutoGit controls in Settings, and prevents background Git refresh/sync work even for repositories that are otherwise Git-backed. Settings remains the re-enable path.
|
||||
|
||||
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
|
||||
|
||||
@@ -697,7 +715,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|--------|---------|
|
||||
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
|
||||
| `git/` | Git operations and shared system-git helpers (`command.rs`, `remote_config.rs`, `commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
|
||||
| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch |
|
||||
| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers |
|
||||
@@ -788,9 +806,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|---------|-------------|
|
||||
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini availability |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini + Kiro availability |
|
||||
| `get_agent_docs_path` | Resolve the bundled local Tolaria docs folder used in AI-agent system prompts |
|
||||
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, or Gemini through the normalized agent event layer |
|
||||
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, Gemini, or Kiro through the normalized agent event layer |
|
||||
| `register_mcp_tools` | Register vault-neutral MCP in Claude/Gemini/Cursor/OpenCode/generic config |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/OpenCode/generic config |
|
||||
| `check_mcp_status` | Check whether Tolaria's durable MCP entry is registered in Claude/Gemini/Cursor/OpenCode/generic config |
|
||||
@@ -848,11 +866,15 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
|
||||
| `useNoteWindowLifecycle` | note-window open/title side effects | Opens `tauri://` note windows without full vault scans and keeps the native title current |
|
||||
| `useStartupScreenState` | startup visibility booleans | Keeps onboarding, telemetry-consent, missing-vault, and initial indexing decisions out of `App.tsx` |
|
||||
| `useAppWindowControls` | view mode, panel visibility, command refs, zoom/build labels | Keeps main-window sizing and editor command ref plumbing out of `App.tsx` |
|
||||
| `useAppViewActions` | saved-view/type creation and saved-view mutation callbacks | Keeps saved-view persistence and Type auto-creation orchestration out of `App.tsx` |
|
||||
| `useAiWorkspacePublishedContext` | AI workspace note-list snapshot and BroadcastChannel context publishing | Keeps AI workspace context derivation close to its cross-window publication side effect |
|
||||
| `useGitFileWorkflows` | git diff/history/discard callbacks | Resolves note-scoped repository paths and owns deleted-file preview and queued diff side effects |
|
||||
| `useVaultRenameDetection` | detected rename banner state | Detects external Git renames on focus and owns the wikilink update callback |
|
||||
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
|
||||
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
|
||||
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
|
||||
| `useDeepLinks` | Deep-link listener and copy actions | Resolves `tolaria://` links into known vault navigation and clipboard URLs |
|
||||
| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup |
|
||||
| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs |
|
||||
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
|
||||
@@ -865,8 +887,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
|
||||
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, Git visibility, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences, Git setup prompt preference, AI permission mode | Vault-specific config |
|
||||
| `appCommandDispatcher` | Manifest-backed shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
@@ -888,7 +910,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
| `[[` in editor | Open wikilink suggestion menu |
|
||||
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, and the breadcrumb overflow menu, all routed through the same editor export hook. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
|
||||
Shortcut routing is explicit:
|
||||
|
||||
@@ -899,7 +921,7 @@ Shortcut routing is explicit:
|
||||
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
|
||||
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
|
||||
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
|
||||
- `menu.rs`, `useMenuEvents`, and the custom titlebar `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions
|
||||
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
|
||||
- Deterministic QA uses two explicit proof paths from the shared manifest:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
@@ -918,18 +940,18 @@ push to main
|
||||
and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN
|
||||
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
|
||||
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
|
||||
→ build job:
|
||||
→ build-artifacts job, via `.github/workflows/release-build-artifacts.yml`:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app
|
||||
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
|
||||
→ build-windows job:
|
||||
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
|
||||
→ build-linux job:
|
||||
→ pnpm install, stamp version
|
||||
→ tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
→ verify Linux installer and updater-signature artifacts exist
|
||||
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
|
||||
→ pnpm install, stamp version, import the Windows code-signing certificate when configured
|
||||
→ tauri build --target x86_64-pc-windows-msvc --bundles nsis, using the Authenticode signing config when certificate secrets exist
|
||||
→ verify the Windows app executable and installer Authenticode signatures with Get-AuthenticodeSignature when Authenticode is configured
|
||||
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
|
||||
→ release job:
|
||||
→ generate alpha-latest.json with darwin-aarch64, darwin-x86_64, Linux, and Windows updater URLs
|
||||
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
|
||||
@@ -947,17 +969,17 @@ Stable promotions trigger `.github/workflows/release-stable.yml`:
|
||||
```
|
||||
push stable-vYYYY.M.D tag
|
||||
→ version job: validate YYYY.M.D from the tag
|
||||
→ build job:
|
||||
→ build-artifacts job, via `.github/workflows/release-build-artifacts.yml`:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin
|
||||
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
|
||||
→ build-linux job:
|
||||
→ pnpm install, stamp version
|
||||
→ tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
→ verify Linux installer and updater-signature artifacts exist
|
||||
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
|
||||
→ build-windows job:
|
||||
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
→ pnpm install, stamp version, import the Windows code-signing certificate
|
||||
→ tauri build --target x86_64-pc-windows-msvc --bundles nsis with Authenticode signing config
|
||||
→ verify the Windows app executable and installer Authenticode signatures with Get-AuthenticodeSignature
|
||||
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
|
||||
→ release job:
|
||||
→ generate stable-latest.json with macOS Apple Silicon, macOS Intel, Linux, and Windows updater URLs plus platform-specific manual download URLs
|
||||
@@ -966,7 +988,7 @@ push stable-vYYYY.M.D tag
|
||||
→ build VitePress public docs into the GitHub Pages root
|
||||
→ build static HTML release history page at /releases/
|
||||
→ publish stable/latest.json
|
||||
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts
|
||||
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts, default Linux visitors to AppImage, require an explicit Windows installer click with managed-device signing guidance, and expose RPM as a manual Linux option when the stable release includes one
|
||||
→ preserve alpha/latest.json
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
@@ -1087,7 +1109,7 @@ Desktop-only modules gated at the crate level:
|
||||
Desktop-only features gated at the function level in `commands/`:
|
||||
- Git operations (commit, pull, push, status, history, diff, conflicts)
|
||||
- Clone-by-URL via system git (`clone_repo`)
|
||||
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi, Gemini)
|
||||
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi, Gemini, Kiro)
|
||||
- MCP registration and status
|
||||
- Menu state updates
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Linux release CI currently uses Tauri's stock linuxdeploy AppImage output plugin
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
```
|
||||
|
||||
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
|
||||
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. Windows release jobs import the CI code-signing certificate, build NSIS with a generated Tauri Authenticode signing config, and verify the app executable plus installer signatures before upload. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -69,9 +69,14 @@ pnpm dev
|
||||
pnpm tauri dev
|
||||
|
||||
# Run tests
|
||||
pnpm test # Vitest unit tests
|
||||
cargo test # Rust tests (from src-tauri/)
|
||||
pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min)
|
||||
pnpm test # Vitest unit tests
|
||||
cargo test # Rust tests (from src-tauri/)
|
||||
|
||||
# Or, run Rust tests from root project directory
|
||||
cargo test --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
# E2E tests
|
||||
pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min)
|
||||
pnpm playwright:regression # Full Playwright regression suite
|
||||
```
|
||||
|
||||
@@ -85,7 +90,7 @@ Linux AppImage builds still use the user's system `git` and `node`. Before Tolar
|
||||
|
||||
The `settings.multi_workspace_enabled` flag turns the registered vault list into a unified graph. When enabled, `useVaultLoader` loads every available mounted vault, annotates entries with workspace provenance, and lets note lists, quick open, keyword search, backlinks, and wikilink navigation span those vaults.
|
||||
|
||||
The selected/default vault remains the write and repository focus. New notes and Type documents use `defaultWorkspacePath` when it points at an available mounted vault, while Git status, commits, sync, folder tree, repair, and watcher behavior stay scoped to explicit repository roots. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault.
|
||||
The selected/default vault remains the write target for new notes and Type documents when `defaultWorkspacePath` points at an available mounted vault. Git status, changes, AutoGit checkpointing, and sync operate across the active mounted repository set, while history, diff, repair, and file operations still resolve explicit repository roots from the selected surface or entry provenance. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault.
|
||||
|
||||
The bottom-left `VaultMenu` exposes quick include/exclude controls and a `Manage vaults` entry. The Vaults settings section owns the full identity controls: display name, short label, read-only alias, accent color, removal, and default destination for new notes.
|
||||
|
||||
@@ -115,7 +120,8 @@ tolaria/
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
|
||||
│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace (docked or native window)
|
||||
│ │ ├── AiPanel.tsx # AI transcript/composer surface (selected target + per-vault permission mode)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
|
||||
@@ -125,7 +131,7 @@ tolaria/
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── LinuxTitlebar.tsx # Linux-only custom window chrome + controls
|
||||
│ │ ├── LinuxTitlebar.tsx # Linux/Windows custom window chrome + controls
|
||||
│ │ ├── LinuxMenuButton.tsx # Linux titlebar menu mirroring app commands
|
||||
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
|
||||
│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later
|
||||
@@ -151,7 +157,7 @@ tolaria/
|
||||
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
|
||||
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini/Kiro availability polling
|
||||
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
@@ -222,8 +228,8 @@ tolaria/
|
||||
│ │ ├── frontmatter/ # Frontmatter module
|
||||
│ │ │ ├── mod.rs, yaml.rs, ops.rs
|
||||
│ │ ├── git/ # Git module
|
||||
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs, connect.rs
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ │ ├── mod.rs, command.rs, remote_config.rs, commit.rs, status.rs
|
||||
│ │ │ ├── history.rs, clone.rs, connect.rs, conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── telemetry.rs # Sentry init + path scrubber
|
||||
│ │ ├── search.rs # Keyword search (walkdir-based)
|
||||
│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch
|
||||
@@ -231,6 +237,7 @@ tolaria/
|
||||
│ │ ├── claude_cli.rs # Claude CLI adapter
|
||||
│ │ ├── codex_cli.rs # Codex CLI adapter
|
||||
│ │ ├── pi_cli.rs # Pi CLI adapter
|
||||
│ │ ├── kiro_cli.rs # Kiro CLI adapter
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
|
||||
│ │ ├── app_updater.rs # Alpha/stable updater metadata resolution
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
@@ -239,7 +246,7 @@ tolaria/
|
||||
│ │ └── menu.rs # Native macOS menu bar
|
||||
│ └── icons/ # App icons
|
||||
│
|
||||
├── mcp-server/ # MCP bridge (Node.js)
|
||||
├── mcp-server/ # MCP bridge (Node.js or Bun)
|
||||
│ ├── index.js # MCP server entry (stdio, 14 tools)
|
||||
│ ├── vault.js # Vault file operations
|
||||
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
|
||||
@@ -307,7 +314,7 @@ tolaria/
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
|
||||
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
|
||||
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
|
||||
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs`, `src-tauri/src/kiro_cli.rs` | Per-agent command, config, discovery, and event adapters. |
|
||||
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — resolves alpha/stable manifests and streams install progress. |
|
||||
|
||||
### Editor
|
||||
@@ -326,7 +333,9 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
|
||||
| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace — sidebar chats, installed-only target picker, permission picker, and dock/pop-out controls. |
|
||||
| `src/components/AiPanel.tsx` | Reusable AI transcript/composer surface — selected target with tool execution, reasoning, actions, and per-vault permission mode. |
|
||||
| `src/utils/openAiWorkspaceWindow.ts` | Native Tauri AI workspace window creation, focus, and dock-back traffic-light handling. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
|
||||
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
|
||||
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
|
||||
@@ -345,12 +354,12 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, default note width, sidebar type pluralization, default AI agent). |
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, Git visibility, auto-sync interval, default note width, sidebar type pluralization, default AI agent). |
|
||||
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
|
||||
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
|
||||
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, Git setup prompt preference, AI permission mode). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, Git visibility, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
|
||||
|
||||
## Architecture Patterns
|
||||
@@ -386,7 +395,7 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux and Windows, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because those builds use Tolaria's custom chrome instead of the native desktop menu bar. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
@@ -467,12 +476,13 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
|
||||
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
|
||||
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103.
|
||||
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`, `kiro_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi, Gemini, and Kiro on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103. Kiro receives prompt content over stdin and writes Tolaria MCP config into `.kiro/settings/mcp.json` in the active vault.
|
||||
7. **Availability probing**: Edit `src/hooks/useAiAgentsStatus.ts` and `src-tauri/src/ai_agents.rs` for AI-agent install/status detection. Keep renderer probing deferred until after first paint, skip it when AI features or AI surfaces are unavailable, and keep backend per-agent CLI checks parallel so missing tools do not serialize shell startup cost.
|
||||
|
||||
### Work with external MCP setup
|
||||
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs` and its `src-tauri/src/mcp/` helpers; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use a vault-neutral entry with `WS_UI_PORT=9711`. Linux AppImage startup must extract `mcp-server/` to `~/.local/share/tolaria/mcp-server/` before durable registration uses that stable path. App-owned bridge launches still pass `VAULT_PATH`/`VAULT_PATHS`; durable external registrations rely on the Node MCP server reading `vaults.json` at tool-call time.
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs` and its `src-tauri/src/mcp/` helpers; registration and manual config generation must resolve an MCP runtime via `find_mcp_runtime` (Node.js 18+ preferred, Bun 1+ fallback) first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use a vault-neutral entry with `WS_UI_PORT=9711`. Linux AppImage startup must extract `mcp-server/` to `~/.local/share/tolaria/mcp-server/` before durable registration uses that stable path. App-owned bridge launches still pass `VAULT_PATH`/`VAULT_PATHS`; durable external registrations rely on the MCP server reading `vaults.json` at tool-call time.
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the runtime prerequisite (Node.js 18+ or Bun 1+), the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
|
||||
5. **OpenCode compatibility**: Keep `~/.config/opencode/opencode.json` in durable registration. OpenCode uses the top-level `mcp` key, `command` as an array, `environment` for env vars, `type: "local"`, and `enabled: true`; it must remain vault-neutral like the standard `mcpServers` entry.
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
type: ADR
|
||||
id: "0026"
|
||||
title: "Props-down callbacks-up (no global state management)"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-02-15
|
||||
superseded_by: "0115"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
type: ADR
|
||||
id: "0110"
|
||||
title: "In-app media and PDF previews for binary vault files"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-05-05
|
||||
supersedes: "0098"
|
||||
superseded_by: "0121"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0115"
|
||||
title: "Scoped React Context for shared UI preferences"
|
||||
status: active
|
||||
date: 2026-05-12
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa has relied on props-down callbacks-up state flow since ADR-0026 because most renderer state is orchestrated in `App.tsx` and the component tree stays understandable. Today's `date_display_format` refactor exposed a narrow exception: the same installation-local rendering preference now needs to reach note rows, property chips and cells, inspector surfaces, table-of-contents metadata, search subtitles, and date-editing controls across multiple branches of the tree. Continuing to thread that value through intermediate components would add noisy prop plumbing to components that do not conceptually own the preference.
|
||||
|
||||
## Decision
|
||||
|
||||
**Use a scoped React context for shared UI preferences that are read in many renderer leaves but still sourced from `App.tsx`. `AppPreferencesProvider` publishes the current installation-local preference values, and leaf components consume them through focused hooks such as `useDateDisplayFormat`; writes still flow through the existing settings/update path rather than through context mutations.**
|
||||
|
||||
## Alternatives considered
|
||||
- **Scoped app-preferences context** (chosen): removes prop forwarding for cross-cutting rendering preferences while keeping the source of truth in `App.tsx` and avoiding a general-purpose global store.
|
||||
- **Continue prop drilling from `App.tsx`**: preserves the old rule literally, but keeps widening component signatures and couples intermediate components to preferences they do not use.
|
||||
- **Adopt a broader global state/store solution**: centralizes access, but introduces more indirection and policy surface than this renderer-only preference case needs.
|
||||
|
||||
## Consequences
|
||||
|
||||
Leaf components can read shared formatting preferences directly, so `date_display_format` stays consistent across note-list, inspector, search, and metadata surfaces without forwarding props through unrelated layers.
|
||||
|
||||
This narrows ADR-0026's blanket "no Context for data" rule. The replacement rule is: mutable application/domain state still lives in `App.tsx` plus focused hooks, while React context is allowed only for tightly scoped, cross-cutting UI preferences whose canonical value still originates from that same top-level state.
|
||||
|
||||
Future additions to `AppPreferencesProvider` should stay small, renderer-local, and read-focused. If Laputa starts moving writable domain state, async workflows, or large derived objects into context, that needs a new ADR rather than quietly expanding this pattern.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0121"
|
||||
title: "AppImage external fallback for audio and video previews"
|
||||
status: active
|
||||
date: 2026-05-15
|
||||
supersedes: "0110"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0110 standardized in-app previews for image, audio, video, and PDF vault files through the shared `FilePreview` surface and Tauri asset URLs. In practice, Linux AppImage builds run audio and video playback through WebKitGTK, and that runtime has proven unstable enough that mounting the same in-webview media controls is not a reliable default for packaged Linux releases.
|
||||
|
||||
Tolaria still needs one binary-preview model across platforms: previewability should remain renderer-inferred from filename extensions, binary files should remain ordinary vault entries, and external-open actions must continue to re-enter the active-vault command boundary before the OS opens a file.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps in-app image and PDF previews everywhere, but Linux AppImage builds fall back to external-open controls for audio and video instead of mounting in-webview media playback.**
|
||||
|
||||
- `FilePreview` remains the single renderer-owned surface for supported binary vault files.
|
||||
- The preview policy is runtime-owned: the renderer asks the native runtime whether external media fallback is required before rendering audio or video elements.
|
||||
- Linux AppImage builds return `true` for that runtime check and suppress in-webview audio/video previews; other targets keep the existing native HTML media controls.
|
||||
- Editor-embedded BlockNote audio/video blocks follow the same runtime gate so binary preview behavior stays consistent between note bodies and file previews.
|
||||
|
||||
## Alternatives considered
|
||||
- **Runtime-gated external fallback on Linux AppImage** (chosen): keeps one preview architecture while containing a platform-specific runtime instability. Cons: AppImage users lose inline playback for audio/video.
|
||||
- **Keep in-app audio/video previews on every platform**: preserves feature parity, but continues shipping a known unstable playback path on AppImage.
|
||||
- **Disable all binary previews on Linux**: simpler policy, but unnecessarily removes stable image/PDF previews and weakens the file-first editor experience.
|
||||
|
||||
## Consequences
|
||||
|
||||
Tolaria now treats audio/video preview as a runtime capability decision rather than a universal guarantee of the binary preview system. Linux AppImage users see explicit external-open fallback controls for audio and video, while other platforms keep the richer in-app playback path.
|
||||
|
||||
This keeps the filesystem-first binary model, scoped asset access, and active-vault validation boundary intact without introducing persisted media types or a separate media subsystem. Re-evaluate this decision if AppImage media playback becomes stable enough to restore inline playback without special handling, or if other packaged runtimes need their own preview capability gates.
|
||||
33
docs/adr/0122-scalar-array-frontmatter-properties.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 0122. Scalar Array Frontmatter Properties
|
||||
|
||||
Status: active
|
||||
|
||||
Date: 2026-05-15
|
||||
|
||||
## Context
|
||||
|
||||
Saved Views filter against `VaultEntry.properties` in the renderer and in the Rust view evaluator. Before this decision, Tolaria preserved custom scalar frontmatter values as properties but dropped multi-element non-wikilink arrays during a full vault scan. That made a view such as `tags / contains / blues` unstable: optimistic renderer state could see a changed array for a while, but reload, view switch, or restart rebuilt the entry without the array-backed property.
|
||||
|
||||
Relationship arrays already have separate semantics because wikilink-bearing fields are stored in `VaultEntry.relationships`. Plain scalar arrays need to stay queryable as custom properties without being treated as relationship fields.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria preserves custom scalar-array frontmatter fields in `VaultEntry.properties`, while wikilink-bearing arrays remain relationships.** Single-item scalar arrays continue to normalize to a scalar value for compatibility; multi-item scalar arrays remain arrays.
|
||||
|
||||
Saved View filters evaluate scalar-array properties with set semantics. `contains` and `any_of` match exact case-insensitive elements, not substrings inside an element. Scalar properties keep their existing case-insensitive text matching behavior.
|
||||
|
||||
The vault cache version is bumped so existing caches that dropped array properties are rebuilt from disk.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A (chosen): Preserve scalar arrays as properties** - keeps YAML frontmatter expressive, fixes reload/restart behavior, and avoids hardcoded fields such as `tags`. The cost is widening `VaultEntry.properties` from scalar-only to scalar-or-array.
|
||||
- **Option B: Flatten arrays to comma-delimited strings** - keeps the old property type, but cannot distinguish exact elements from substrings and makes filters ambiguous.
|
||||
- **Option C: Treat every array as a relationship** - reuses existing relationship matching, but non-wikilink values such as tags are not graph edges and should not appear as relationships.
|
||||
|
||||
## Consequences
|
||||
|
||||
Views can filter custom scalar arrays consistently across save, reload, view switches, and app restart.
|
||||
|
||||
Property chips and sorting must tolerate property arrays. The note-list chip resolver already expands array values; custom-property sorting falls back to string comparison for arrays.
|
||||
|
||||
Any future custom-property logic must handle `VaultPropertyValue` rather than assuming every property is a scalar.
|
||||
32
docs/adr/0123-full-vault-graph-for-secondary-note-windows.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0123"
|
||||
title: "Full vault graph for secondary note windows"
|
||||
status: active
|
||||
date: 2026-05-25
|
||||
supersedes: "0118"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0118 made secondary note windows entry-scoped to avoid repeated full-vault scans. That reduced startup work, but it also removed the vault-index context that normal Tolaria capabilities depend on: properties, view actions, quick open/search, workspace-aware navigation, and other command surfaces no longer behaved like the main window.
|
||||
|
||||
The product expectation is that opening a note in a separate window creates another capable Tolaria window, not a reduced editor shell. Performance remains important, but capability parity is the stronger contract.
|
||||
|
||||
## Decision
|
||||
|
||||
**Secondary note windows load the same active vault/workspace graph as a normal Tolaria window.** They still start in editor-only view mode and auto-open the requested note from the URL parameters, but the app keeps the shared vault loader, mounted-workspace filtering, watcher scope, editor entry list, and workspace-aware note actions enabled.
|
||||
|
||||
`main.tsx` always mounts `App`; note-window mode is handled inside `App` through `getNoteWindowParams()` and `useNoteWindowLifecycle`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Entry-scoped note windows**: faster startup, but loses app-level features that require repository-wide context. Rejected because it breaks the secondary-window product contract.
|
||||
- **Full app with full active graph** (chosen): keeps one window architecture and restores feature parity. Trade-off: each secondary window performs its own vault load.
|
||||
- **Shared state from the main window over IPC**: could provide parity without duplicate scans, but adds synchronization complexity and failure modes. Deferred until profiling proves the duplicate load is a real bottleneck.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Secondary windows can use normal Tolaria capabilities such as Properties, view actions, quick open/search, wikilink navigation, and workspace-aware note operations.
|
||||
- Opening several note windows can repeat vault-loading work. This is acceptable for now because correctness and parity are more important than avoiding the scan.
|
||||
- ADR-0118 is superseded. If secondary-window startup becomes too slow, optimize with shared state or incremental loading without removing app capabilities.
|
||||
27
docs/adr/0124-cached-secondary-note-window-startup.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0124"
|
||||
title: "Cached secondary note window startup"
|
||||
status: active
|
||||
date: 2026-05-26
|
||||
supersedes: "0123"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0123 restored secondary note windows to the normal `App` path so they retain the full vault/workspace graph required by Properties, quick open/search, wikilinks, and workspace-aware note actions.
|
||||
|
||||
That parity is still the product contract, but forcing a fresh Tauri `reload_vault` during every secondary-window mount invalidates the backend cache. Opening several note windows can therefore repeat expensive full-vault scans even when the main window has already warmed the cache.
|
||||
|
||||
## Decision
|
||||
|
||||
**Secondary note windows keep the full vault graph, but their initial vault load uses the cached/incremental `list_vault` path instead of the forced `reload_vault` path.**
|
||||
|
||||
Normal main-window startup continues to force a fresh initial reload. Explicit refresh paths, watcher-driven refreshes, and user-initiated reloads still use reload commands where they need disk freshness.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Secondary note windows remain capable full app windows rather than reduced editor shells.
|
||||
- Repeated note-window opens can reuse the backend vault cache instead of invalidating it on every startup.
|
||||
- First open after a cold cache still scans the vault, then warms the shared backend cache for later windows.
|
||||
- If the cached scan path is stale, the existing backend cache update logic remains responsible for incremental freshness.
|
||||
29
docs/adr/0126-renderer-action-history.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
id: "0126"
|
||||
title: "Renderer action history for app-level undo and redo"
|
||||
status: "active"
|
||||
date: "2026-05-26"
|
||||
supersedes:
|
||||
- "0106"
|
||||
---
|
||||
|
||||
# ADR-0126: Renderer action history for app-level undo and redo
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already lets native text surfaces keep their own undo stacks, but app-level state changes such as frontmatter edits, archive toggles, favorite toggles, and organization toggles did not share a consistent undo/redo model. Routing all Undo and Redo through the native menu items left these app actions one-way while also making command-palette discoverability inconsistent.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a renderer-owned `useActionHistory` stack for app-level actions. Supported actions record explicit undo and redo callbacks only after the write succeeds, clear redo after new user actions, and suppress nested recordings while a history entry is replaying.
|
||||
|
||||
The Edit menu and command manifest now route Undo and Redo to renderer commands. Focused text-editing controls still receive native text history first through `document.execCommand('undo' | 'redo')`, so editor/input undo behavior remains separate from the app action stack.
|
||||
|
||||
Destructive actions that are not safely reversible remain outside this stack and continue to rely on confirmation/destructive UX instead of pretending to be undoable.
|
||||
|
||||
## Consequences
|
||||
|
||||
- App-level history is scoped to the active renderer session and is not persisted across launches.
|
||||
- Undo/redo labels can be surfaced in the command palette because the top stack entries expose labels.
|
||||
- Menu accelerators and keyboard shortcuts use the shared command manifest instead of Tauri's native Undo/Redo menu builders.
|
||||
- ADR-0106 remains valid for the broader menu ownership model, but its native Undo/Redo exception is superseded by this renderer action-history route.
|
||||
32
docs/adr/0127-native-ai-workspace-window.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0127"
|
||||
title: "Native AI workspace window"
|
||||
status: active
|
||||
date: 2026-05-26
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The AI panel used to behave like another right-side editor panel. That kept the agent UI inside the main Tolaria window even when the user undocked it, so the "floating" surface could not be moved to another macOS space or placed beside Tolaria as a real window.
|
||||
|
||||
The AI surface also needed to support multiple chat sessions, per-chat target selection, and a single header that does not duplicate the old panel title and permission controls.
|
||||
|
||||
## Decision
|
||||
|
||||
**The AI surface is a renderer-owned `AiWorkspace` that can run either docked in the main app or in a dedicated native Tauri webview window labeled `ai-workspace`.**
|
||||
|
||||
The docked and native-window modes share the same React workspace component. The native window boots the normal `App` path with `?window=ai-workspace`, skips main-window size constraints, and uses macOS overlay traffic lights. Close and minimize requests from that window emit a dock request back to the main window before destroying the pop-out window.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Native Tauri window** (chosen): gives macOS users real window movement, traffic lights, and normal desktop window management; requires route/window-mode plumbing and explicit dock events.
|
||||
- **CSS floating panel inside the main window**: simple and preserves component state in one renderer, but it cannot leave the main window bounds and fails the expected macOS behavior.
|
||||
- **Separate full AI app shell**: isolates the workspace, but would duplicate vault loading and settings flows more than necessary.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The status-bar AI affordance opens the workspace; target selection now belongs in the workspace header.
|
||||
- `AiWorkspace` owns multi-chat sidebar state and filters target choices to installed local agents plus configured local/API model providers.
|
||||
- The old `AiPanel` remains the reusable transcript/composer surface, but its header and prompt/focus effects can be disabled when mounted inside workspace sessions.
|
||||
- Pop-out/dock currently transfers the workspace at the window level; future persistence can promote active conversations into a shared store if users need exact in-flight chat reparenting across renderer instances.
|
||||
35
docs/adr/0128-lightweight-ai-workspace-window.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0128"
|
||||
title: "Lightweight AI workspace window"
|
||||
status: active
|
||||
date: 2026-05-26
|
||||
supersedes: "0127"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0127 moved the AI workspace into a native Tauri window, but the first version booted the full `App` shell and used macOS overlay traffic lights. That made pop-out feel slow, duplicated startup work, and left the chat route dependent on main-window vault loading before agent turns could run.
|
||||
|
||||
The AI workspace also needs installation-local chat metadata so user-facing chat titles and archive state survive dock/pop-out transitions without writing to a vault.
|
||||
|
||||
## Decision
|
||||
|
||||
**The AI workspace pop-out uses a lightweight renderer route backed by app settings metadata.**
|
||||
|
||||
`openAiWorkspaceWindow()` opens the `ai-workspace` Tauri webview with `?window=ai-workspace` plus active vault context in URL params. `App` routes that window directly to `AiWorkspaceWindowApp`, which loads settings, AI agent status, and vault guidance without mounting the full vault/editor shell. The window is undecorated and transparent, and it relies on `AiWorkspace` headers for drag regions plus separate close and dock controls. Close only closes the pop-out; dock emits the main-window dock request before closing the pop-out.
|
||||
|
||||
`settings.ai_workspace_conversations` stores only chat sidebar metadata: conversation id, title, archive state, and explicit target override. Prompt text, transcripts, note content, model credentials, and vault-local configuration stay out of app settings.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Lightweight AI route** (chosen): keeps pop-out startup focused on AI state and passes explicit vault context to the agent controller.
|
||||
- **Full `App` route**: preserves maximum feature parity by default, but repeats vault/editor startup work and delays a window that should contain only the AI workspace.
|
||||
- **Vault-stored chat metadata**: would travel with a vault, but chat labels and archive state are installation UI preferences rather than vault content.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Pop-out startup avoids full note graph loading and should be close to instant after the Tauri webview is created.
|
||||
- The dedicated AI window has no native traffic lights; users close or redock it through separate workspace header controls, and the rounded workspace shell defines the visible floating-window corners.
|
||||
- Chat titles, archived state, and target overrides persist at the installation level in `settings.json`.
|
||||
- Future transcript persistence must use a separate storage decision; `ai_workspace_conversations` is intentionally metadata-only.
|
||||
45
docs/adr/0129-tolaria-vault-item-deep-links.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0129"
|
||||
title: "Tolaria vault item deep links"
|
||||
status: active
|
||||
date: 2026-05-27
|
||||
---
|
||||
|
||||
# ADR-0129: Tolaria vault item deep links
|
||||
|
||||
## Context
|
||||
|
||||
Users need durable links they can paste into calendars, task managers, chats, and other apps to return to a Tolaria vault item. The link needs to identify a registered vault, preserve the file extension so non-Markdown files can be opened, and fail clearly when the vault or item is unavailable. Links must not create or import files implicitly.
|
||||
|
||||
Mounted workspaces make vault naming non-trivial. A readable slug is useful, but two vaults can share a label, alias, or folder name. URL parsing also cannot rely only on the browser URL implementation because dot-segment normalization can hide path traversal attempts before validation runs.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria deep links use this shape:
|
||||
|
||||
```text
|
||||
tolaria://<vault-slug>/<relative-path-with-extension>
|
||||
```
|
||||
|
||||
The vault slug is generated from the registered workspace alias, then label, then path basename. When two vaults would share the same base slug, generated links append a stable short hash derived from the normalized vault path. A handwritten ambiguous base slug is rejected instead of picking an arbitrary vault.
|
||||
|
||||
The path component is encoded per segment with `encodeURIComponent`, so spaces, Unicode, and reserved characters are preserved while `/` remains the path separator. Parsing rejects empty path segments, `.`, `..`, decoded separators inside a segment, unsafe Windows separators, and resolved paths outside the selected vault root.
|
||||
|
||||
`src/utils/deepLinks.ts` owns URL building, parsing, and vault resolution. `src/hooks/useDeepLinks.ts` owns renderer integration: it receives Tauri deep-link events, validates them against the registered vault list, switches vaults when needed, reloads once if the target file is not in the current index yet, opens existing Markdown/text/binary entries, reports localized errors, and emits safe PostHog outcomes. Deep links are navigation-only; they never create missing files, import external content, or infer a fallback vault.
|
||||
|
||||
The desktop shell registers the `tolaria` scheme through `tauri-plugin-deep-link` and keeps second launches focused through `tauri-plugin-single-instance`. Windows and Linux also call runtime `register_all()` as a repair step. macOS uses bundle scheme registration. Linux runtime registration is best-effort and is not part of the verified v1 support target.
|
||||
|
||||
Copy surfaces are shared actions:
|
||||
|
||||
- Breadcrumb overflow: `Copy note deeplink`
|
||||
- Command palette: `Copy deep link to current item`
|
||||
- File preview header: copy action for non-Markdown vault files
|
||||
|
||||
## Consequences
|
||||
|
||||
Path-based links are understandable and support every vault file kind, but a file rename changes the old link. A future stable item-id layer could supersede this URL shape while preserving path links as a readable fallback.
|
||||
|
||||
Collision handling keeps generated links deterministic without exposing full local paths. Ambiguous handwritten slugs fail clearly, which is safer than opening the wrong vault.
|
||||
|
||||
Renderer-owned resolution keeps the navigation logic close to mounted-workspace state and note selection. Native plugins stay responsible only for scheme registration, event delivery, and focusing the existing app instance.
|
||||
36
docs/adr/0130-windows-authenticode-release-signing.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0130"
|
||||
title: "Windows Authenticode signing for release installers"
|
||||
status: active
|
||||
date: 2026-05-27
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's Windows release job already produced Tauri updater signatures, but those signatures are not the Windows trust signal used by SmartScreen, Smart App Control, Defender, or WDAC policies when a user downloads and runs an installer from the browser. A managed Windows 11 user reported that the stable NSIS installer was blocked by Windows Security with no bypass option.
|
||||
|
||||
Microsoft's current guidance is that unsigned public installers can be fully blocked by enterprise policy, while signed installers at least carry a publisher identity and can build reputation across releases. Store distribution would provide the strongest SmartScreen outcome, but Tolaria does not currently publish a Microsoft Store package.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria release CI must Authenticode-sign Windows app executables and installers before publishing them.**
|
||||
|
||||
- Alpha and stable Windows release jobs import a CI-provided code-signing certificate from GitHub secrets.
|
||||
- The workflow generates a temporary Tauri config that sets `bundle.windows.certificateThumbprint`, `digestAlgorithm`, and `timestampUrl`, then passes that config to `pnpm tauri build`.
|
||||
- The Windows job verifies the produced app executable and installer artifacts with `Get-AuthenticodeSignature` and fails before upload if any signature is missing, invalid, or signed by an unexpected certificate.
|
||||
- The public stable download page requires an explicit Windows installer click and tells managed-device users that IT may need to approve the Tolaria publisher before first install.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **CI-enforced Authenticode signing** (chosen): gives Windows users and enterprise admins a real publisher identity, lets certificate reputation transfer across releases, and blocks accidental publication of unsigned installers. Cons: release jobs now depend on code-signing secrets and a valid certificate.
|
||||
- **Documentation-only SmartScreen warning**: cheaper, but it leaves managed-device users with no supported path when policy removes the bypass option.
|
||||
- **Microsoft Store distribution only**: strongest SmartScreen behavior, but it requires a separate packaging, submission, and release-management path that Tolaria does not yet own.
|
||||
- **Portable ZIP fallback**: still downloads executable content from the browser and can remain subject to SmartScreen, Mark-of-the-Web, Smart App Control, or WDAC policy.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Windows release failures caused by missing or expired code-signing credentials are intentional release blockers.
|
||||
- Tauri updater signatures remain required for in-app updates, but they are treated as separate from Windows Authenticode trust.
|
||||
- Enterprise-managed Windows installs can be documented around a stable Tolaria publisher identity instead of asking users to disable security policy.
|
||||
- A future Microsoft Store/MSIX distribution path can supersede or supplement this policy if Tolaria decides to support Store-managed installs.
|
||||
29
docs/adr/0131-reusable-release-artifact-build-workflow.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0131"
|
||||
title: "Reusable release artifact build workflow"
|
||||
status: active
|
||||
date: 2026-05-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's alpha and stable release workflows both need to build the same platform artifact set: dual-architecture macOS updater bundles, optional stable macOS DMGs, Linux bundles, and signed Windows installers/updater bundles. Keeping those build jobs copied into both release workflows made platform fixes and validation changes easy to apply in one channel while accidentally leaving the other channel behind.
|
||||
|
||||
The release workflows still differ in how they compute versions, create releases, and publish alpha vs. stable metadata, but the artifact build contract is shared.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria centralizes release artifact production in `.github/workflows/release-build-artifacts.yml`, invoked by alpha and stable release workflows through `workflow_call`.** Channel-specific workflows own versioning and publishing; the shared workflow owns platform build, signing, validation, and artifact upload behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Reusable artifact workflow** (chosen): keeps alpha and stable artifact behavior aligned while preserving separate channel-specific release orchestration. Cons: release behavior is split across one caller workflow and one called workflow, so debugging requires following both files.
|
||||
- **Keep duplicated jobs in each release workflow**: makes each workflow self-contained, but every platform build fix must be applied twice and drift is likely.
|
||||
- **Merge alpha and stable releases into one workflow**: reduces workflow count, but couples different trigger/version/publishing semantics and makes the release pipeline harder to reason about.
|
||||
|
||||
## Consequences
|
||||
|
||||
Alpha and stable releases now share one platform artifact contract, including macOS, Linux, and Windows validation. Changes to signing, cache keys, bundle validation, or platform matrices should happen in the reusable artifact workflow unless they are genuinely channel-specific.
|
||||
|
||||
The architecture documentation should describe the release pipeline as channel orchestration plus shared artifact production, not as independent duplicated build job sets.
|
||||
25
docs/adr/0132-alpha-authenticode-soft-gate.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0132"
|
||||
title: "Alpha Authenticode soft gate"
|
||||
status: active
|
||||
date: 2026-05-28
|
||||
amends: "0130"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0130 made Windows Authenticode signing mandatory for release installers. That is still the right requirement for stable promotions, but the repository does not yet have the Windows code-signing certificate secrets needed by CI. Because alpha releases run on every push to `main`, requiring those secrets there broke the continuous alpha channel before the certificate provisioning work was complete.
|
||||
|
||||
## Decision
|
||||
|
||||
**Alpha Windows artifacts keep building when Authenticode certificate secrets are absent; stable Windows artifacts still require Authenticode signing.**
|
||||
|
||||
- The shared release artifact workflow accepts `require_windows_authenticode`.
|
||||
- Alpha passes `false`, emits a workflow warning when certificate secrets are absent, and still requires Tauri updater signatures.
|
||||
- Stable passes `true` and fails before building Windows artifacts unless certificate and password secrets are configured.
|
||||
- When certificate secrets are present, both channels use the generated Tauri Authenticode config and verify Windows executable/installer signatures before upload.
|
||||
|
||||
## Consequences
|
||||
|
||||
The alpha updater channel remains live while Windows certificate provisioning is underway. Stable releases continue to enforce the Windows trust policy from ADR 0130 before public promotion. Once the certificate secrets are configured, alpha builds automatically regain Authenticode signing without another workflow change.
|
||||
27
docs/adr/0133-request-scoped-ai-stream-events.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0133"
|
||||
title: "Request-scoped AI stream event channels"
|
||||
status: active
|
||||
date: 2026-05-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
AI agent streams and direct model streams used shared Tauri event names (`ai-agent-stream` and `ai-model-stream`). That worked while only one stream of each kind was active, but the side AI workspace and pop-out window can make concurrent or rapidly reused AI sessions more likely. Shared channels risk routing deltas, tool events, or completion events to the wrong renderer listener.
|
||||
|
||||
## Decision
|
||||
|
||||
**Each native AI stream uses a request-scoped Tauri event channel generated by the renderer and validated by the backend.**
|
||||
|
||||
The renderer creates a unique event name with the stream's stable base prefix, listens on that channel, and passes it to the `stream_ai_agent` or `stream_ai_model` command as `event_name`. The Rust command accepts only scoped names that match the expected base prefix and safe character set; invalid or missing names fall back to the legacy shared channel.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Request-scoped renderer channel** (chosen): isolates simultaneous streams without changing the stream event payload shape, while preserving backward-compatible command defaults.
|
||||
- **Keep shared static event names**: simpler, but concurrent agent/model sessions can cross-deliver stream events between workspaces or chats.
|
||||
- **Backend-generated channel names**: centralizes validation, but requires a setup handshake before the renderer can subscribe and complicates the current fire-and-stream command flow.
|
||||
|
||||
## Consequences
|
||||
|
||||
Concurrent AI streams can run without contaminating each other's renderer callbacks. The backend keeps a narrow validation boundary for externally supplied event names. Any future AI streaming command should follow the same base-prefix plus scoped-suffix convention instead of adding another process-wide static channel.
|
||||
27
docs/adr/0134-direct-shiki-language-registrations.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0134"
|
||||
title: "Direct Shiki language registrations for code blocks"
|
||||
status: active
|
||||
date: 2026-05-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria uses `@blocknote/code-block` for rich-editor fenced-code highlighting. The bundled BlockNote highlighter covers the common web and systems languages already in the editor menu, but it does not include several common Shiki grammars such as PowerShell, VBScript, Dart, Dockerfile, Terraform/HCL, and TOML. Users still expect imported fences like `powershell`, `ps1`, `vb`, and `vbscript` to highlight, show a valid language picker state, and serialize back to a stable Markdown fence.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps BlockNote's code-block integration and adds direct, lazy `@shikijs/langs` registrations for missing common languages and aliases.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Keep only the BlockNote bundle**: simplest, but leaves PowerShell/VBScript and other common fences unsupported.
|
||||
- **Register selected `@shikijs/langs` grammars lazily** (chosen): preserves BlockNote's schema and parser path while adding only the extra grammars users need.
|
||||
- **Replace BlockNote's highlighter with a full custom Shiki bundle**: more control, but a larger structural change than the current requirement needs.
|
||||
|
||||
## Consequences
|
||||
|
||||
`src/components/codeBlockOptions.ts` remains the owner of the BlockNote highlighter configuration, but extra grammar modules are now imported directly from `@shikijs/langs` only when a matching fence or picker value needs highlighting. `src/utils/codeBlockLanguageCatalog.ts` owns the supported extra language labels and aliases, and `src/utils/codeBlockLanguage.ts` normalizes known imported aliases such as `ps1` and `vb` to the canonical picker language.
|
||||
|
||||
The language menu grows, but unsupported aliases still fail safely by staying as plain explicit fence names. If Tolaria later needs a generated language bundle, export-time highlighting, or a substantially smaller menu, this ADR should be superseded by a custom Shiki packaging decision.
|
||||
@@ -81,7 +81,7 @@ proposed → active → superseded
|
||||
| [0023](0023-repair-vault-auto-bootstrap.md) | Repair Vault auto-bootstrap pattern | active |
|
||||
| [0024](0024-cache-outside-vault.md) | Vault cache stored outside vault directory | active |
|
||||
| [0025](0025-type-field-canonical.md) | type: as canonical field (replacing Is A:) | active |
|
||||
| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | active |
|
||||
| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | superseded → [0115](0115-scoped-react-context-for-shared-ui-preferences.md) |
|
||||
| [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | superseded |
|
||||
| [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active |
|
||||
| [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active |
|
||||
@@ -163,12 +163,26 @@ proposed → active → superseded
|
||||
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |
|
||||
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
|
||||
| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active |
|
||||
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | active |
|
||||
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | superseded → [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) |
|
||||
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active |
|
||||
| [0112](0112-system-theme-mode.md) | System theme mode | active |
|
||||
| [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active |
|
||||
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
|
||||
| [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | Scoped React Context for shared UI preferences | active |
|
||||
| [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active |
|
||||
| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | active |
|
||||
| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | superseded -> [0123](0123-full-vault-graph-for-secondary-note-windows.md) |
|
||||
| [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active |
|
||||
| [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active |
|
||||
| [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | AppImage external fallback for audio and video previews | active |
|
||||
| [0122](0122-scalar-array-frontmatter-properties.md) | Scalar array frontmatter properties | active |
|
||||
| [0123](0123-full-vault-graph-for-secondary-note-windows.md) | Full vault graph for secondary note windows | superseded -> [0124](0124-cached-secondary-note-window-startup.md) |
|
||||
| [0124](0124-cached-secondary-note-window-startup.md) | Cached secondary note window startup | active |
|
||||
| [0126](0126-renderer-action-history.md) | Renderer action history for app-level undo and redo | active |
|
||||
| [0127](0127-native-ai-workspace-window.md) | Native AI workspace window | superseded -> [0128](0128-lightweight-ai-workspace-window.md) |
|
||||
| [0128](0128-lightweight-ai-workspace-window.md) | Lightweight AI workspace window | active |
|
||||
| [0129](0129-tolaria-vault-item-deep-links.md) | Tolaria vault item deep links | active |
|
||||
| [0130](0130-windows-authenticode-release-signing.md) | Windows Authenticode signing for release installers | active |
|
||||
| [0131](0131-reusable-release-artifact-build-workflow.md) | Reusable release artifact build workflow | active |
|
||||
| [0132](0132-alpha-authenticode-soft-gate.md) | Alpha Authenticode soft gate | active |
|
||||
| [0133](0133-request-scoped-ai-stream-events.md) | Request-scoped AI stream event channels | active |
|
||||
| [0134](0134-direct-shiki-language-registrations.md) | Direct Shiki language registrations for code blocks | active |
|
||||
|
||||
@@ -224,7 +224,7 @@ test('full create note flow', async ({ page }) => {
|
||||
|
||||
// Count should increase
|
||||
const countAfter = await page.locator('.note-list__count').textContent()
|
||||
expect(parseInt(countAfter!)).toBe(parseInt(countBefore!) + 1)
|
||||
expect(parseInt(countAfter!, 10)).toBe(parseInt(countBefore!, 10) + 1)
|
||||
|
||||
// Note should be opened in editor
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/E2E Test Note/)
|
||||
|
||||
@@ -32,7 +32,7 @@ test('visual verify: editor theme + list indentation', async ({ page }) => {
|
||||
window.getComputedStyle(el).paddingLeft
|
||||
)
|
||||
console.log(`Level-0 padding-left: ${paddingL0}`)
|
||||
expect(parseInt(paddingL0)).toBe(40)
|
||||
expect(parseInt(paddingL0, 10)).toBe(40)
|
||||
|
||||
// Bullet widgets and checkboxes are rendered
|
||||
const bulletCount = await page.locator('.cm-live-bullet').count()
|
||||
|
||||
89
index.html
@@ -7,25 +7,6 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
background: #FFFFFF;
|
||||
color: #37352F;
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
background: #1F1E1B;
|
||||
color: #E6E1D8;
|
||||
}
|
||||
body {
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
<title>Tolaria</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function () {
|
||||
function isResizeObserverLoopMessage(message) {
|
||||
@@ -47,6 +28,63 @@
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (new URLSearchParams(window.location.search).get('window') === 'ai-workspace') {
|
||||
document.documentElement.classList.add('ai-workspace-native-window');
|
||||
}
|
||||
} catch {
|
||||
// Leave the normal app chrome untouched if URL parsing is unavailable.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
background: #FFFFFF;
|
||||
color: #37352F;
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
background: #1F1E1B;
|
||||
color: #E6E1D8;
|
||||
}
|
||||
body {
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
:root.ai-workspace-native-window,
|
||||
:root.ai-workspace-native-window body {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
:root.ai-workspace-native-window body {
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
}
|
||||
:root.ai-workspace-native-window #root {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E9E9E7;
|
||||
border-radius: 12px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12), 0 1px 3px rgba(15, 23, 42, 0.10);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
:root.ai-workspace-native-window[data-theme="dark"] #root {
|
||||
background: #1F1E1B;
|
||||
border-color: #34322D;
|
||||
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.30), 0 1px 4px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
</style>
|
||||
<title>Tolaria</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function () {
|
||||
var key = 'tolaria-theme';
|
||||
var legacyKey = 'laputa-theme';
|
||||
var systemDarkQuery = '(prefers-color-scheme: dark)';
|
||||
@@ -56,7 +94,7 @@
|
||||
function prefersDarkTheme() {
|
||||
try {
|
||||
return typeof window.matchMedia === 'function' && window.matchMedia(systemDarkQuery).matches;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -69,14 +107,15 @@
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
}
|
||||
var mode;
|
||||
try {
|
||||
var mode = normalizeTheme(localStorage.getItem(key));
|
||||
mode = normalizeTheme(localStorage.getItem(key));
|
||||
if (mode === null) {
|
||||
mode = normalizeTheme(localStorage.getItem(legacyKey));
|
||||
if (mode !== null) localStorage.setItem(key, mode);
|
||||
}
|
||||
applyTheme(mode);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
applyTheme('light');
|
||||
}
|
||||
})();
|
||||
@@ -93,7 +132,7 @@
|
||||
function hasReloadAttempted() {
|
||||
try {
|
||||
return sessionStorage.getItem(reloadAttemptKey) === '1';
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +141,7 @@
|
||||
try {
|
||||
sessionStorage.setItem(reloadAttemptKey, '1');
|
||||
return true;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -110,7 +149,7 @@
|
||||
function clearReloadAttempt() {
|
||||
try {
|
||||
sessionStorage.removeItem(reloadAttemptKey);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened WebView/privacy modes.
|
||||
}
|
||||
}
|
||||
|
||||
96
lara.lock
@@ -36,9 +36,14 @@ files:
|
||||
command.navigation.showArchivedNotes: 241399b908884adfe8608a3ab827ca74
|
||||
command.navigation.listType: 4f70c27c6826a37543c8ada561397c22
|
||||
command.note.newNote: 23c4edda8b815f750782f01870d27271
|
||||
command.note.newNoteInCurrentFolder: 167b765903c70ab1b645908ff8a899ab
|
||||
command.note.newType: adce5108f18945cc502a06c02445e32d
|
||||
command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2
|
||||
command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047
|
||||
command.note.undo: 1cdc076b28f70afac5fcedadf99fa119
|
||||
command.note.undoAction: 83a1f43c314533161109d8d4daf032f2
|
||||
command.note.redo: 5afeaba074ef570dc720caaa855d49f6
|
||||
command.note.redoAction: 7556c9dd6845991933c56006bf7ffbac
|
||||
command.note.pastePlainText: 09e8075dbd91e11878f8f4a138df760c
|
||||
command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc
|
||||
command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1
|
||||
@@ -54,11 +59,14 @@ files:
|
||||
command.note.removeIcon: 4bccbad66025e506b37b4218498b299c
|
||||
command.note.changeType: 4a54ffd47ed1f65ee97f34bfe8c3de14
|
||||
command.note.moveToFolder: 3a0f26c42b9168ad839960d134065905
|
||||
command.note.copyDeepLink: 8de1795f59af124594dc4decf77909a1
|
||||
command.note.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0
|
||||
command.note.openNewWindow: 6f67b2857093fa0fd45b1122dff4865d
|
||||
command.git.initialize: 54a57cdc7105db6fb22dca661ce01ad6
|
||||
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
|
||||
command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e
|
||||
command.git.pull: 3988d61f4a4546381f61a4eaf61315b4
|
||||
command.git.pullRepository: 931ad25176586fd4332dd671574d3185
|
||||
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
|
||||
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
|
||||
git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2
|
||||
@@ -141,9 +149,12 @@ files:
|
||||
settings.language.label: 244c7b77926f077b863c33ff1244e1ec
|
||||
settings.language.system: 41e2252344aa441e925589ddcb762e6d
|
||||
settings.language.summary: edc83f1f48ce29bc80bf2f2f90e24997
|
||||
settings.autogit.title: e59f720605ec1cfa42b3b97d89d7e883
|
||||
settings.autogit.title: 0bcc70105ad279503e31fe7b3f47b665
|
||||
settings.git.enable: c40503e1b0f483eecf3aff2fd01665c4
|
||||
settings.git.enableDescription: a96a43dc4dbf1490d665eef437807b07
|
||||
settings.autogit.description.enabled: e65bec70ca4d7921be56f84d10836018
|
||||
settings.autogit.description.disabled: 1143ef6ee00614816785e3c6fc299d51
|
||||
settings.autogit.description.gitDisabled: 55a1dd2d7f797062cde1374930221649
|
||||
settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75
|
||||
settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554
|
||||
settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6
|
||||
@@ -179,12 +190,12 @@ files:
|
||||
settings.allNotesVisibility.unsupported: 257fe04419c9f6543a43e58b1edf9eb5
|
||||
settings.allNotesVisibility.unsupportedDescription: a3b8aa66900c742f001cd4533b3df44a
|
||||
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
|
||||
settings.aiAgents.description: 042a8037f1a4f90c76ce31a49fdff59c
|
||||
settings.aiAgents.description: 768c138c3432fb159ac58c5a4471e786
|
||||
settings.aiFeatures.enable: af47a6b2bd32a7b7309bac7bef34f026
|
||||
settings.aiFeatures.enableDescription: 7d3916f7f9e1bf8b8797f50f4533b9da
|
||||
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
|
||||
settings.aiAgents.defaultTarget: 5acfc74fd97a6dd2d67d15c66de5eed5
|
||||
settings.aiAgents.agentGroup: 965530cbbec45b1754ed3ece120399f7
|
||||
settings.aiAgents.agentGroup: 11eaa7f817e3f24d54f3cf3025be49b1
|
||||
settings.aiAgents.localGroup: d34f4b997ce78da5aa57d657a5d6fcb5
|
||||
settings.aiAgents.apiGroup: 589a257cbd265eaa7de93de8b4084dff
|
||||
settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff
|
||||
@@ -195,11 +206,11 @@ files:
|
||||
settings.aiAgents.apiLocalKey: 119de2f31867b273a134f05bc56b9e57
|
||||
settings.aiAgents.apiEnv: e011db3dff54ed3deae37a9615e31ebf
|
||||
settings.aiAgents.apiNoKey: 5bbccbfcd09b28902bbf5319dd6e6053
|
||||
settings.aiAgents.installedTitle: 5cb9b5ae5ebc66652814dff4584705cc
|
||||
settings.aiAgents.installedDescription: fb9ba9667725fb40fa1e5d58a2c02692
|
||||
settings.aiAgents.installedTitle: 0de6536215f76c44351ba4d809804c7a
|
||||
settings.aiAgents.installedDescription: 27c8ed48439ac4a613409f5303fbca84
|
||||
settings.aiAgents.noVersion: 89831adb7114946e916b9c31d57a1e33
|
||||
settings.aiProviders.title: 63eb84d8510f9f086d282ec7d47adaa1
|
||||
settings.aiProviders.description: 6c86c013eb6fb198b51e29ec47d66464
|
||||
settings.aiProviders.description: d29dfa440713d15de311d29a64a27767
|
||||
settings.aiProviders.localTitle: c7c20443d8aaf04271df9d713f5c02ce
|
||||
settings.aiProviders.localDescription: 7abed40ef2e46741c64ff47e423cca39
|
||||
settings.aiProviders.apiTitle: 66537b9e5200ebbbfbd091144d8afbcc
|
||||
@@ -295,7 +306,7 @@ files:
|
||||
ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b
|
||||
ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26
|
||||
ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950
|
||||
ai.panel.empty.withContextTitle: 66f02985937083e4ef62b98cc9f25aee
|
||||
ai.panel.empty.withContextTitle: cea2547385aeeb68fa494bb3b09b98f5
|
||||
ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9
|
||||
ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb
|
||||
ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54
|
||||
@@ -303,6 +314,50 @@ files:
|
||||
ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9
|
||||
ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114
|
||||
ai.panel.send: 747c6a3564774149c989884e0c581d53
|
||||
ai.message.regenerate: e464a5330788a9f79d06c96989f840cb
|
||||
ai.message.copy: 53fa249ce3d02c8a686b657b31d02ee7
|
||||
ai.message.fork: f5aace5e5b5e7b0954651a2da44b6755
|
||||
ai.workspace.title: 375099f0c804e924c1dc275370710d92
|
||||
ai.workspace.newChat: a917baaf1484ec5dc85f19ab8fc4f4e9
|
||||
ai.workspace.collapseSidebar: 9ecc763686b7c61547281f3e05ffd06f
|
||||
ai.workspace.expandSidebar: 281b619f3da2ba27e604302c3c00db56
|
||||
ai.workspace.close: 184649ab030e9a609488608221c28d8c
|
||||
ai.workspace.expandPanel: edbe17b531457367c1412e4b6349c194
|
||||
ai.workspace.restorePanel: 64eb6176c678c6b395cfc93bac02a16f
|
||||
ai.workspace.restoreGuidance: 2bd339d85ee3b33e513359ce781b60cc
|
||||
ai.workspace.closeChat: 6c5be35688a466297db40c3a65ac9805
|
||||
ai.workspace.popOut: 85ec25ec056b3082651e6e3fcfa57416
|
||||
ai.workspace.dock: 72e81ed23007ec6fb9820b9abec353c4
|
||||
ai.workspace.settings: 2c178b1d7a60a3c2b37ca4221d322899
|
||||
ai.workspace.archive: a02bc089273a8c460ea3d2cd2c257b59
|
||||
ai.workspace.restore: 98ee8b364870e27dd48aca3240801abf
|
||||
ai.workspace.showArchived: ae2bcb4cb837cc09a5767153efe4bf39
|
||||
ai.workspace.hideArchived: e7c72757d5bc7330d876eeb1b6ce9c53
|
||||
ai.workspace.noActiveChats: f0cdbf09f23eb9a2efbb5977bcc39c1a
|
||||
ai.workspace.noArchivedChats: 68410efd12fc0308ce1ed2225000d4d5
|
||||
ai.workspace.chatTitle: 189013cfac88018c68ec02e8edc2379b
|
||||
ai.workspace.renameChat: 0feba25e166f55afc51c4ebb33d8d757
|
||||
ai.workspace.status.archived: 7d69b3cb4cada18ae61811304f8fbcb5
|
||||
ai.workspace.status.idle: e599161956d626eda4cb0a5ffb85271c
|
||||
ai.workspace.status.running: 5bda814c4aedb126839228f1a3d92f09
|
||||
ai.workspace.status.done: f92965e2c8a7afb3c1b9a5c09a263636
|
||||
ai.workspace.status.error: 902b0d55fddef6f8d651fe1035b7d4bd
|
||||
ai.workspace.permissionMode: 5a7983f759c9e15ce53d7326f0abad71
|
||||
ai.workspace.guidanceWarning: 00d95da9bc82394785728dec587b026e
|
||||
ai.workspace.targetLabel: df5dd38c32585fe0c71c955b3a31f83c
|
||||
ai.workspace.targetLocalAgents: 5a6a65e960093bfc37b4cc81c4a9f773
|
||||
ai.workspace.targetLocalModels: c7c20443d8aaf04271df9d713f5c02ce
|
||||
ai.workspace.targetApiModels: 66537b9e5200ebbbfbd091144d8afbcc
|
||||
ai.workspace.noTargets: 7c3ed1f10281b41070e5a5cbe77453d7
|
||||
ai.workspace.target.localAgents: 5a6a65e960093bfc37b4cc81c4a9f773
|
||||
ai.workspace.target.localModels: c7c20443d8aaf04271df9d713f5c02ce
|
||||
ai.workspace.target.apiModels: 66537b9e5200ebbbfbd091144d8afbcc
|
||||
ai.workspace.target.none: 7c3ed1f10281b41070e5a5cbe77453d7
|
||||
ai.workspace.target.unavailable: 453e6aa38d87b28ccae545967c53004f
|
||||
ai.workspace.target.installed: 98dd43dfae05b11befe1f140e0ec787a
|
||||
ai.workspace.target.installedVersion: 8677756d761e4cce240a637805482946
|
||||
ai.workspace.target.localModel: d34f4b997ce78da5aa57d657a5d6fcb5
|
||||
ai.workspace.target.apiModel: 589a257cbd265eaa7de93de8b4084dff
|
||||
mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01
|
||||
mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c
|
||||
mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa
|
||||
@@ -312,7 +367,7 @@ files:
|
||||
mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac
|
||||
mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee
|
||||
mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871
|
||||
mcp.setup.nodeRequirement: ac58a35cf21d2204fed548c227f77594
|
||||
mcp.setup.runtimeRequirement: 1d7a8c99b2bc875b3601fa57860432fb
|
||||
mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6
|
||||
mcp.setup.clientPathsDescription: 94565852412051c799e78d2f4525ff53
|
||||
mcp.setup.geminiGuidanceDescription: 182cf5b84b2f56cb1cf41dcdb5e093d8
|
||||
@@ -362,6 +417,7 @@ files:
|
||||
sidebar.action.createFolder: 5dc1e97c14fbe72d8b566ce29c39f010
|
||||
sidebar.action.renameFolder: db76034f8218cda07dadb746a5643019
|
||||
sidebar.action.deleteFolder: 2a2f15300f6f7c81d69860ac1796b517
|
||||
sidebar.action.createNodeInFolderMenu: 857a479229a4ab0bc62bbfb621d3180a
|
||||
sidebar.action.revealFolderMenu: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
sidebar.action.copyFolderPathMenu: 1779ec6018a385912c1a5499a1bbf2b2
|
||||
sidebar.action.renameFolderMenu: deb1d8fa030292e7eda2c244b313aca2
|
||||
@@ -383,6 +439,8 @@ files:
|
||||
noteList.title.notes: f4c6f851b00d5518bf888815de279aba
|
||||
noteList.searchPlaceholder: 4857cd2689a0a40eb4a77cdc745c518e
|
||||
noteList.searchAction: 5012120fc480c67686dd22ee2f9493cd
|
||||
noteList.clearSearch: 9983381c21069a3d6e4432e0aaea0079
|
||||
noteList.quickOpenCreate: a4e964a3bc5b25e7ae87163624731e05
|
||||
noteList.createNote: f1484bc40bd3fe3430c13fb89e2ce1af
|
||||
noteList.empty.changesError: 4fca500c4b70f61f7d9413c57c34bdc4
|
||||
noteList.empty.noChanges: ad82ac153532f5625760c29c176c5d5f
|
||||
@@ -466,8 +524,26 @@ files:
|
||||
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
|
||||
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
|
||||
editor.toolbar.copyNoteDeepLink: 0fff274a7c47e7c12457c0b7412e86ce
|
||||
editor.toolbar.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0
|
||||
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
|
||||
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
|
||||
editor.exportPdf.unavailable: 2fce9834984172b64b66767485d815e9
|
||||
editor.exportPdf.failed: bf3fa78ff5725f5cbde8979b67ec2e2a
|
||||
filePreview.copyDeepLink: b75833669968adbef634495636e3fe50
|
||||
deepLinks.copied: 6ada57681192daf3b4afa07ce7a30575
|
||||
deepLinks.error.ambiguousVault: 4365c6d5cf8c2c1165259b32d24a6274
|
||||
deepLinks.error.copyFailed: 57b74ecce2d00b4c327d15830f79410d
|
||||
deepLinks.error.invalidScheme: 58eaa7ac701a0508cbae90c5d3837ce4
|
||||
deepLinks.error.malformedUrl: 4f85323322ef0a1d627df621edb749c7
|
||||
deepLinks.error.missingFile: 79a0c73176508c58b4d785fabaf7d8cc
|
||||
deepLinks.error.missingPath: a6bb1c92f1a38476ff603a2d3ff81223
|
||||
deepLinks.error.missingVault: e2936e1f599c7ed229efb5d4a122fc4b
|
||||
deepLinks.error.openFailed: 97901feafe25688fcc3fc4dcfaf82619
|
||||
deepLinks.error.outsideVault: fda2fd5675b596df7ba0e0b1a11ec47d
|
||||
deepLinks.error.unavailableVault: 911dfaf8e855bd8cadefe7df9a98a7e8
|
||||
deepLinks.error.unknownVault: b4940569e884833f50466177dab1f62f
|
||||
deepLinks.error.unsafePath: e09caab43be8c2372bfc94e947ea34e6
|
||||
editor.slash.math: a49950aa047c2292e989e368a97a3aae
|
||||
tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b
|
||||
tableOfContents.close: d3292972a10edd1a06a627f3552f760a
|
||||
@@ -624,6 +700,7 @@ files:
|
||||
status.ai.vaultGuidance: 7bb4644efe6ae7cec9993c8bd85f1519
|
||||
status.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
|
||||
status.ai.openOptions: d387b1ab67586c7a2d83db8900a4dd8e
|
||||
status.ai.openWorkspace: 178247429f30251ce21265570b612e9f
|
||||
status.ai.modelTargets: 8f3ceb96e088f8bdaecc236778401ac9
|
||||
status.ai.localChat: 215a24e3d38fb8b68deea6dad0142326
|
||||
status.ai.apiChat: 6dffd5b9997885efe701631cea5c470d
|
||||
@@ -657,3 +734,6 @@ files:
|
||||
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
|
||||
locale.vi: 7b80fae85640c16cdb0261bef0c27636
|
||||
locale.plPL: c730389bc8d99e59c867766babdd48b5
|
||||
locale.beBY: 0a6de4460095f398e8a45ed512a7538f
|
||||
locale.beLatn: 9c454dccccd6aaac0121a45b923dc5ad
|
||||
locale.idID: cc1c9b57607b4439578b68074a5e2d80
|
||||
|
||||
@@ -26,6 +26,12 @@ import path from 'node:path'
|
||||
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
})
|
||||
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
@@ -107,6 +113,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'search_notes',
|
||||
description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -119,6 +126,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'get_vault_context',
|
||||
description: 'Get vault orientation for the active Tolaria vaults: entity types, AGENTS.md instructions, note count, folders, and recent notes.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -129,6 +137,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'list_vaults',
|
||||
description: 'List the current active Tolaria vaults available to MCP tools, including whether each vault has AGENTS.md instructions.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
@@ -137,6 +146,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'get_note',
|
||||
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -149,6 +159,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -161,6 +172,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -173,6 +185,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -9,6 +9,8 @@ import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { clearTimeout, setTimeout } from 'node:timers'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
@@ -305,6 +307,34 @@ describe('requireVaultPath', () => {
|
||||
})
|
||||
|
||||
describe('stdio process lifecycle', () => {
|
||||
it('advertises local vault tools as approval-safe for MCP clients', async () => {
|
||||
const { client, stderr } = await connectMcpClient()
|
||||
|
||||
try {
|
||||
const { tools } = await client.listTools()
|
||||
const toolsByName = new Map(tools.map(tool => [tool.name, tool]))
|
||||
const safeReadTools = [
|
||||
'search_notes',
|
||||
'get_vault_context',
|
||||
'list_vaults',
|
||||
'get_note',
|
||||
'open_note',
|
||||
'highlight_editor',
|
||||
'refresh_vault',
|
||||
]
|
||||
|
||||
for (const name of safeReadTools) {
|
||||
const tool = toolsByName.get(name)
|
||||
assert.ok(tool, `Missing MCP tool: ${name}`)
|
||||
assert.equal(tool.annotations?.readOnlyHint, true, `${name} should not require destructive approval`)
|
||||
assert.equal(tool.annotations?.destructiveHint, false, `${name} should not be treated as destructive`)
|
||||
assert.equal(tool.annotations?.openWorldHint, false, `${name} should stay scoped to local active vaults`)
|
||||
}
|
||||
} finally {
|
||||
await closeMcpClient(client, stderr)
|
||||
}
|
||||
})
|
||||
|
||||
it('exits when the MCP client closes stdin', async () => {
|
||||
const child = spawn(process.execPath, ['index.js'], {
|
||||
cwd: MCP_SERVER_DIR,
|
||||
@@ -332,6 +362,41 @@ describe('stdio process lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
async function connectMcpClient() {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ['index.js'],
|
||||
cwd: MCP_SERVER_DIR,
|
||||
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const stderr = collectTransportStderr(transport)
|
||||
const client = new Client(
|
||||
{ name: 'tolaria-mcp-test-client', version: '0.0.0' },
|
||||
{ capabilities: {} },
|
||||
)
|
||||
|
||||
await client.connect(transport)
|
||||
return { client, stderr }
|
||||
}
|
||||
|
||||
function collectTransportStderr(transport) {
|
||||
const chunks = []
|
||||
transport.stderr?.setEncoding('utf8')
|
||||
transport.stderr?.on('data', chunk => {
|
||||
chunks.push(chunk)
|
||||
})
|
||||
return () => chunks.join('')
|
||||
}
|
||||
|
||||
async function closeMcpClient(client, stderr) {
|
||||
try {
|
||||
await client.close()
|
||||
} catch (error) {
|
||||
assert.fail(`Failed to close MCP test client: ${error.message}\n${stderr()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
|
||||
24
package.json
@@ -12,7 +12,7 @@
|
||||
"docs:dev": "vitepress dev site --host 127.0.0.1",
|
||||
"docs:build": "pnpm agent-docs && vitepress build site",
|
||||
"docs:preview": "vitepress preview site --host 127.0.0.1",
|
||||
"lint": "eslint .",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"l10n:translate": "lara-cli translate",
|
||||
"l10n:translate:force": "lara-cli translate --force",
|
||||
"l10n:validate": "node scripts/validate-locales.mjs",
|
||||
@@ -21,7 +21,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/note-history-edit-loop.spec.ts tests/smoke/save-before-note-switch.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/note-history-edit-loop.spec.ts tests/smoke/quick-open-create-note.spec.ts tests/smoke/save-before-note-switch.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
@@ -53,8 +53,10 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.47.0",
|
||||
"@shikijs/langs": "3.23.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-deep-link": "2.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
@@ -119,10 +121,26 @@
|
||||
"mermaid>uuid": "11.1.1",
|
||||
"path-to-regexp": "8.4.0",
|
||||
"fast-uri": "3.1.2",
|
||||
"fast-xml-builder": "1.1.7",
|
||||
"flatted": "3.4.2",
|
||||
"minimatch@3.1.2": "3.1.5",
|
||||
"minimatch@3.1.3": "3.1.5",
|
||||
"minimatch@9.0.5": "9.0.9",
|
||||
"minimatch@9.0.6": "9.0.9",
|
||||
"picomatch": "4.0.4",
|
||||
"postcss": "8.5.10",
|
||||
"protobufjs": "7.5.6",
|
||||
"rollup": "4.59.0"
|
||||
"qs": "6.15.2",
|
||||
"rollup": "4.59.0",
|
||||
"undici": "7.25.0",
|
||||
"@blocknote/core>uuid": "11.1.1"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@blocknote/core@0.46.2": "patches/@blocknote__core@0.46.2.patch",
|
||||
"@blocknote/react@0.46.2": "patches/@blocknote__react@0.46.2.patch",
|
||||
"@tiptap/extension-link@3.19.0": "patches/@tiptap__extension-link@3.19.0.patch",
|
||||
"prosemirror-tables@1.8.5": "patches/prosemirror-tables@1.8.5.patch",
|
||||
"@blocknote/code-block@0.46.2": "patches/@blocknote__code-block@0.46.2.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
patches/@blocknote__code-block@0.46.2.patch
Normal file
104
pnpm-lock.yaml
generated
@@ -12,12 +12,24 @@ overrides:
|
||||
mermaid>uuid: 11.1.1
|
||||
path-to-regexp: 8.4.0
|
||||
fast-uri: 3.1.2
|
||||
fast-xml-builder: 1.1.7
|
||||
flatted: 3.4.2
|
||||
minimatch@3.1.2: 3.1.5
|
||||
minimatch@3.1.3: 3.1.5
|
||||
minimatch@9.0.5: 9.0.9
|
||||
minimatch@9.0.6: 9.0.9
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.10
|
||||
protobufjs: 7.5.6
|
||||
qs: 6.15.2
|
||||
rollup: 4.59.0
|
||||
undici: 7.25.0
|
||||
'@blocknote/core>uuid': 11.1.1
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/code-block@0.46.2':
|
||||
hash: c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8
|
||||
path: patches/@blocknote__code-block@0.46.2.patch
|
||||
'@blocknote/core@0.46.2':
|
||||
hash: 9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2
|
||||
path: patches/@blocknote__core@0.46.2.patch
|
||||
@@ -40,7 +52,7 @@ importers:
|
||||
version: 0.78.0(zod@4.3.6)
|
||||
'@blocknote/code-block':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
version: 0.46.2(patch_hash=c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8)(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
'@blocknote/core':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
@@ -110,12 +122,18 @@ importers:
|
||||
'@sentry/react':
|
||||
specifier: ^10.47.0
|
||||
version: 10.47.0(react@19.2.4)
|
||||
'@shikijs/langs':
|
||||
specifier: 3.23.0
|
||||
version: 3.23.0
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@7.3.2(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-deep-link':
|
||||
specifier: 2.4.9
|
||||
version: 2.4.9
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -2445,6 +2463,9 @@ packages:
|
||||
'@tauri-apps/api@2.10.1':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
'@tauri-apps/api@2.11.0':
|
||||
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.0':
|
||||
resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -2521,6 +2542,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-deep-link@2.4.9':
|
||||
resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==}
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.6.0':
|
||||
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
|
||||
|
||||
@@ -3324,8 +3348,8 @@ packages:
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
brace-expansion@2.1.0:
|
||||
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
|
||||
|
||||
brace-expansion@5.0.5:
|
||||
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
|
||||
@@ -3949,8 +3973,8 @@ packages:
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
fast-xml-builder@1.1.5:
|
||||
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
|
||||
fast-xml-builder@1.1.7:
|
||||
resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==}
|
||||
|
||||
fast-xml-parser@5.7.2:
|
||||
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
|
||||
@@ -3989,8 +4013,8 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
focus-trap@7.8.0:
|
||||
resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==}
|
||||
@@ -4765,11 +4789,11 @@ packages:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minipass@7.1.3:
|
||||
@@ -5072,8 +5096,8 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.15.0:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
qs@6.15.2:
|
||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
query-selector-shadow-dom@1.0.1:
|
||||
@@ -5561,8 +5585,8 @@ packages:
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
undici@7.22.0:
|
||||
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
|
||||
undici@7.25.0:
|
||||
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicode-emoji-json@0.8.0:
|
||||
@@ -5658,10 +5682,6 @@ packages:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -6211,7 +6231,7 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
'@blocknote/code-block@0.46.2(patch_hash=c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8)(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@shikijs/core': 3.23.0
|
||||
@@ -6259,7 +6279,7 @@ snapshots:
|
||||
remark-stringify: 11.0.0
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.1.0
|
||||
uuid: 8.3.2
|
||||
uuid: 11.1.1
|
||||
y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)
|
||||
y-protocols: 1.0.7(yjs@13.6.29)
|
||||
yjs: 13.6.29
|
||||
@@ -6666,7 +6686,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.7
|
||||
debug: 4.4.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6687,7 +6707,7 @@ snapshots:
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -8156,6 +8176,8 @@ snapshots:
|
||||
|
||||
'@tauri-apps/api@2.10.1': {}
|
||||
|
||||
'@tauri-apps/api@2.11.0': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.0':
|
||||
optional: true
|
||||
|
||||
@@ -8203,6 +8225,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.0
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.0
|
||||
|
||||
'@tauri-apps/plugin-deep-link@2.4.9':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.6.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
@@ -8870,7 +8896,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.55.0
|
||||
'@typescript-eslint/visitor-keys': 8.55.0
|
||||
debug: 4.4.3
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.9
|
||||
semver: 7.7.4
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
@@ -9191,7 +9217,7 @@ snapshots:
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.0
|
||||
qs: 6.15.2
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
@@ -9202,7 +9228,7 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
brace-expansion@2.1.0:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -9773,7 +9799,7 @@ snapshots:
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
@@ -9852,7 +9878,7 @@ snapshots:
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.0
|
||||
qs: 6.15.2
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.2.1
|
||||
@@ -9879,14 +9905,14 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
fast-xml-builder@1.1.5:
|
||||
fast-xml-builder@1.1.7:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.5.0
|
||||
|
||||
fast-xml-parser@5.7.2:
|
||||
dependencies:
|
||||
'@nodable/entities': 2.1.0
|
||||
fast-xml-builder: 1.1.5
|
||||
fast-xml-builder: 1.1.7
|
||||
path-expression-matcher: 1.5.0
|
||||
strnum: 2.2.3
|
||||
|
||||
@@ -9918,12 +9944,12 @@ snapshots:
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.3.3
|
||||
flatted: 3.4.2
|
||||
keyv: 4.5.4
|
||||
|
||||
flat@6.0.1: {}
|
||||
|
||||
flatted@3.3.3: {}
|
||||
flatted@3.4.2: {}
|
||||
|
||||
focus-trap@7.8.0:
|
||||
dependencies:
|
||||
@@ -10356,7 +10382,7 @@ snapshots:
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.0
|
||||
undici: 7.22.0
|
||||
undici: 7.25.0
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
@@ -10940,13 +10966,13 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.5
|
||||
|
||||
minimatch@3.1.2:
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
minimatch@9.0.5:
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.0
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
@@ -11284,7 +11310,7 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.0:
|
||||
qs@6.15.2:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
@@ -11915,7 +11941,7 @@ snapshots:
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@7.22.0: {}
|
||||
undici@7.25.0: {}
|
||||
|
||||
unicode-emoji-json@0.8.0: {}
|
||||
|
||||
@@ -12009,8 +12035,6 @@ snapshots:
|
||||
|
||||
uuid@11.1.1: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vfile-location@5.0.3:
|
||||
|
||||
8
public/ai-agent-icons/SOURCES.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# AI Agent Icons
|
||||
|
||||
- Claude Code: Claude mark from the official Claude wordmark on https://claude.com/product/claude-code
|
||||
- Codex: OpenAI mark from https://openai.com/favicon.svg
|
||||
- OpenCode: official OpenCode brand asset from https://opencode.ai/brand
|
||||
- Pi: official Pi favicon from https://hey.pi.ai
|
||||
- Gemini CLI: Google-hosted Gemini sparkle from https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg
|
||||
- Kiro: official Kiro icon from https://kiro.dev/icon.svg?fe599162bb293ea0
|
||||
3
public/ai-agent-icons/claude-code.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 125 125" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M54.375 118.75L56.125 111L58.125 101L59.75 93L61.25 83.125L62.125 79.875L62 79.625L61.375 79.75L53.875 90L42.5 105.375L33.5 114.875L31.375 115.75L27.625 113.875L28 110.375L30.125 107.375L42.5 91.5L50 81.625L54.875 76L54.75 75.25H54.5L21.5 96.75L15.625 97.5L13 95.125L13.375 91.25L14.625 90L24.5 83.125L49.125 69.375L49.5 68.125L49.125 67.5H47.875L43.75 67.25L29.75 66.875L17.625 66.375L5.75 65.75L2.75 65.125L0 61.375L0.25 59.5L2.75 57.875L6.375 58.125L14.25 58.75L26.125 59.5L34.75 60L47.5 61.375H49.5L49.75 60.5L49.125 60L48.625 59.5L36.25 51.25L23 42.5L16 37.375L12.25 34.75L10.375 32.375L9.625 27.125L13 23.375L17.625 23.75L18.75 24L23.375 27.625L33.25 35.25L46.25 44.875L48.125 46.375L49 45.875V45.5L48.125 44.125L41.125 31.375L33.625 18.375L30.25 13L29.375 9.75C29.0417 8.625 28.875 7.375 28.875 6L32.75 0.750006L34.875 0L40.125 0.750006L42.25 2.625L45.5 10L50.625 21.625L58.75 37.375L61.125 42.125L62.375 46.375L62.875 47.75H63.75V47L64.375 38L65.625 27.125L66.875 13.125L67.25 9.125L69.25 4.375L73.125 1.87501L76.125 3.25L78.625 6.875L78.25 9.125L76.875 18.75L73.875 33.875L72 44.125H73.125L74.375 42.75L79.5 36L88.125 25.25L91.875 21L96.375 16.25L99.25 14H104.625L108.5 19.875L106.75 26L101.25 33L96.625 38.875L90 47.75L86 54.875L86.375 55.375H87.25L102.125 52.125L110.25 50.75L119.75 49.125L124.125 51.125L124.625 53.125L122.875 57.375L112.625 59.875L100.625 62.25L82.75 66.5L82.5 66.625L82.75 67L90.75 67.75L94.25 68H102.75L118.5 69.125L122.625 71.875L125 75.125L124.625 77.75L118.25 80.875L109.75 78.875L89.75 74.125L83 72.5H82V73L87.75 78.625L98.125 88L111.25 100.125L111.875 103.125L110.25 105.625L108.5 105.375L97 96.625L92.5 92.75L82.5 84.375H81.875V85.25L84.125 88.625L96.375 107L97 112.625L96.125 114.375L92.875 115.5L89.5 114.875L82.25 104.875L74.875 93.5L68.875 83.375L68.25 83.875L64.625 121.625L63 123.5L59.25 125L56.125 122.625L54.375 118.75Z" fill="#D97757" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
3
public/ai-agent-icons/codex.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="41" height="41" viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z" fill="#111111" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
10
public/ai-agent-icons/gemini.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 28C14 26.0633 13.6267 24.2433 12.88 22.54C12.1567 20.8367 11.165 19.355 9.905 18.095C8.645 16.835 7.16333 15.8433 5.46 15.12C3.75667 14.3733 1.93667 14 0 14C1.93667 14 3.75667 13.6383 5.46 12.915C7.16333 12.1683 8.645 11.165 9.905 9.905C11.165 8.645 12.1567 7.16333 12.88 5.46C13.6267 3.75667 14 1.93667 14 0C14 1.93667 14.3617 3.75667 15.085 5.46C15.8317 7.16333 16.835 8.645 18.095 9.905C19.355 11.165 20.8367 12.1683 22.54 12.915C24.2433 13.6383 26.0633 14 28 14C26.0633 14 24.2433 14.3733 22.54 15.12C20.8367 15.8433 19.355 16.835 18.095 18.095C16.835 19.355 15.8317 20.8367 15.085 22.54C14.3617 24.2433 14 26.0633 14 28Z" fill="url(#paint0_radial_16771_53212)" />
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_16771_53212" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.77876 11.3795) rotate(18.6832) scale(29.8025 238.737)">
|
||||
<stop offset="0.0671246" stop-color="#9168C0" />
|
||||
<stop offset="0.342551" stop-color="#5684D1" />
|
||||
<stop offset="0.672076" stop-color="#1BA1E3" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
11
public/ai-agent-icons/kiro.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="1200" rx="260" fill="#9046FF" />
|
||||
<mask id="mask0_1106_4856" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="272" y="202" width="655" height="796">
|
||||
<path d="M926.578 202.793H272.637V997.857H926.578V202.793Z" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_1106_4856)">
|
||||
<path d="M398.554 818.914C316.315 1001.03 491.477 1046.74 620.672 940.156C658.687 1059.66 801.052 970.473 852.234 877.795C964.787 673.567 919.318 465.357 907.64 422.374C827.637 129.443 427.623 128.946 358.8 423.865C342.651 475.544 342.402 534.18 333.458 595.051C328.986 625.86 325.507 645.488 313.83 677.785C306.873 696.424 297.68 712.819 282.773 740.645C259.915 783.881 269.604 867.113 387.87 823.883L399.051 818.914H398.554Z" fill="white" />
|
||||
<path d="M636.123 549.353C603.328 549.353 598.359 510.097 598.359 486.742C598.359 465.623 602.086 448.977 609.293 438.293C615.504 428.852 624.697 424.131 636.123 424.131C647.555 424.131 657.492 428.852 664.447 438.541C672.398 449.474 676.623 466.12 676.623 486.742C676.623 525.998 661.471 549.353 636.375 549.353H636.123Z" fill="black" />
|
||||
<path d="M771.24 549.353C738.445 549.353 733.477 510.097 733.477 486.742C733.477 465.623 737.203 448.977 744.41 438.293C750.621 428.852 759.814 424.131 771.24 424.131C782.672 424.131 792.609 428.852 799.564 438.541C807.516 449.474 811.74 466.12 811.74 486.742C811.74 525.998 796.588 549.353 771.492 549.353H771.24Z" fill="black" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
18
public/ai-agent-icons/opencode.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0_1401_86274)">
|
||||
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86274)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#CFCECD" />
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86274">
|
||||
<rect width="240" height="300" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 713 B |
11
public/ai-agent-icons/pi.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" fill="none">
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#FAF3EA" d="M-14-14h208v208H-14z" />
|
||||
<path fill="#038247" d="M121.084 57.922c0 7.408 6.061 13.469 13.469 13.469 7.408 0 13.469-6.061 13.469-13.47 0-7.407-6.061-13.468-13.469-13.468-7.408 0-13.469 6.06-13.469 13.469Zm-5.657 81.622v-.135c0-2.828 1.617-4.579 5.388-5.792 3.771-1.347 4.714-2.289 4.714-7.273v-24.648c0-4.984-1.347-5.657-5.118-6.87-3.098-.942-4.984-2.424-4.984-4.713 0-1.886 1.213-3.368 2.963-4.041l19.531-7.004c4.31-1.616 7.677.539 7.677 4.445v42.831c0 4.984.943 5.926 4.714 7.273 3.771 1.213 5.388 2.964 5.388 5.792v.135c0 2.694-2.155 4.579-5.388 4.579h-29.497c-3.233 0-5.388-1.885-5.388-4.579ZM65.053 68.427c0-17.24 2.425-22.897 9.832-22.897 9.025 0 14.278 8.755 14.278 23.706 0 14.95-5.119 23.974-13.47 23.974-8.35 0-10.64-6.6-10.64-24.783ZM27.205 139.41v.135c0 2.694 2.155 4.579 5.388 4.579H76.77c3.233 0 5.388-1.885 5.388-4.579v-.135c0-2.559-1.482-4.445-5.253-5.253-9.428-2.155-10.64-3.098-10.64-8.351v-19.26c0-2.425 1.077-4.041 3.367-4.041s4.175 1.078 8.35 1.078c21.82 0 37.31-14.547 37.31-35.02 0-20.473-14.547-33.672-35.29-33.672-11.313 0-19.664 4.175-30.574 4.175H32.593c-3.233 0-5.388 1.886-5.388 4.58v.134c0 2.829 1.616 4.58 5.388 5.522 6.734 1.617 7.677 2.56 7.677 7.543v69.5c0 4.984-.943 5.926-7.677 7.543-3.772.943-5.388 2.694-5.388 5.522Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<rect width="180" height="180" fill="#fff" rx="45" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
18
release-notes/v2026-05-18.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## New Features
|
||||
|
||||
- 🔎 **Faster Note-List Search Controls** — Clear note-list searches directly from the list header while keeping keyboard focus and loading feedback predictable.
|
||||
- 🧰 **More Granular Git Controls** — Use clearer Git actions for local commit, pull, push, and sync workflows without collapsing everything into one path.
|
||||
- 🐧 **Linux RPM Download Option** — Stable download pages can expose RPM packages alongside AppImage and other platform installers when release artifacts include them.
|
||||
|
||||
## Improvements
|
||||
|
||||
- 🧭 **Cleaner Update Release Notes Link** — The in-app update banner now opens the public release notes page instead of the old GitHub Pages root.
|
||||
- 📝 **More Reliable Editor Direction and Spacing** — Mixed right-to-left and left-to-right notes resolve text direction per block, and raw-editor line numbers align more cleanly with content.
|
||||
- 🧩 **Better View Filtering for Array Properties** — Saved views now match scalar array properties such as tags more consistently after reloads and across renderer/Rust evaluation.
|
||||
- 🖼️ **Safer Linux AppImage Media Handling** — AppImage builds avoid unstable in-webview audio/video preview paths while preserving stable external-open fallbacks.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Rich-editor recovery was hardened for invalid list content, embedded attachment paths, file drops, Mermaid reload clicks, aliased wikilinks in Markdown tables, and empty-heading note edits.
|
||||
- Vault and workspace behavior is steadier around mounted default workspaces, app-owned frontmatter reloads, parsed note-list preload warmups, and built-in note body template removal.
|
||||
- Release build type safety, CodeScene thresholds, image-toolbar hover access, and multiple patch-review findings were addressed before promotion.
|
||||
19
release-notes/v2026-05-21.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## New Features
|
||||
|
||||
- 🧭 **Expanded Note Context Menus** — Right-click notes for faster everyday actions, including opening in a new window, favorites, organization state, Finder reveal, and file path copy.
|
||||
- 🤖 **More Local AI Agent Options** — Use Kiro as a local AI agent, run Tolaria's MCP server through Bun as well as Node, and launch Pi more reliably from shell-managed installs.
|
||||
- 📚 **Portent Knowledge-Base Template** — Tolaria's docs now include Portent as a reference template for structuring durable, agent-friendly knowledge bases.
|
||||
- 🌍 **Belarusian Language Support** — Use Tolaria with new Belarusian interface translations.
|
||||
|
||||
## Improvements
|
||||
|
||||
- ⚡ **Much Faster Note Windows** — Opening a note in a separate window now uses a lighter startup path, avoiding the full main-window load and reducing app-wide stalls.
|
||||
- 🪟 **More Complete Note Actions** — Note-list actions are now available from the context menu as well as existing command paths, making common file and organization tasks easier to reach.
|
||||
- 🧩 **Cleaner CLI Agent Runtime Detection** — Shared binary discovery was consolidated so local agent launches handle shell-managed runtimes more consistently.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Note windows now open reliably from all entry points and no longer stall the whole application during startup.
|
||||
- Editor reliability is improved around Go code-block highlighting, Mermaid fullscreen zoom, non-Markdown wikilink targets, active-note refresh after external edits, and retained editor memory.
|
||||
- Vault and workspace behavior is steadier around AutoGit multi-vault pushes, new views in the default workspace, and vault watcher Git symlinks.
|
||||
- Release build type safety, pnpm patched dependency handling, CodeScene thresholds, and multiple patch-review findings were addressed before promotion.
|
||||
19
release-notes/v2026-05-25.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## New Features
|
||||
|
||||
- 🔎 **Create Notes from Quick Open** — Start a new note directly from Quick Open when your search does not already match an existing note.
|
||||
- 🌓 **Theme-Matched App Icons** — Tolaria can now switch its application icon to match the current light or dark appearance.
|
||||
- 🧭 **Multi-Vault Type Visibility** — Sidebar type controls now distinguish vault and workspace context more clearly when several vaults are open.
|
||||
|
||||
## Improvements
|
||||
|
||||
- 🔄 **Smarter Multi-Vault Git Sync** — Sync actions now target the active Git-backed vaults more consistently, including mounted workspaces.
|
||||
- 🪟 **Better Desktop Window Chrome** — Custom window chrome now exposes the desktop menu more reliably and behaves better on Windows.
|
||||
- 🤖 **More Reliable Local Agent Detection** — Codex, Claude, and OpenCode launch detection handles Windows command shims and Homebrew-style runtime paths more consistently.
|
||||
- ✂️ **Safer Code Block Copying** — Large fenced-code copies are bounded more carefully so editor clipboard actions stay responsive.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Editor recovery was hardened around stale BlockNote references, invalid table transforms, insertion-depth errors, side-menu actions, emoji suggestions, and selection toolbar placement.
|
||||
- Vault and workspace behavior is steadier around empty startup vault reloads, missing frontmatter write paths, active-note refresh focus, and sidebar type visibility across vaults.
|
||||
- Git and AI workflows are more predictable around selected-repository sync, multi-vault autosync, retained AI panel sessions, and agent target selection.
|
||||
- Status-bar layout, external URL cancellation, light icon assets, release CodeScene thresholds, native QA guidance, and new smoke coverage were addressed before promotion.
|
||||
21
release-notes/v2026-05-29.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## New Features
|
||||
|
||||
- 🤖 **Side AI Workspace** — Work with AI in a dedicated side workspace that keeps conversations and context closer to the note you are editing.
|
||||
- ↩️ **Action History Undo and Redo** — Note actions now participate in a clearer undo/redo history, making structural edits easier to reverse.
|
||||
- 📁 **Create Notes Inside Folders** — New notes can be created directly inside the selected folder instead of always starting at the vault root.
|
||||
- 🌏 **Indonesian Language Support** — Tolaria now includes an Indonesian translation alongside the existing supported languages.
|
||||
|
||||
## Improvements
|
||||
|
||||
- 🔎 **Search Note Body Content** — Note-list search can now find matches inside note bodies, not only titles and visible metadata.
|
||||
- 🧭 **Vault Item Deep Links** — Links to specific vault items route more reliably across notes, folders, and workspace surfaces.
|
||||
- ⚡ **Faster AI Agent Startup** — Local AI agent discovery is deferred and parallelized so opening Tolaria does less blocking work up front.
|
||||
- 🪟 **Refined AI Workspace Layout** — The AI workspace side panel, floating button, conversation titles, and shared sessions feel more stable and focused.
|
||||
- 🔄 **More Reliable Git Remote Handling** — Git remote detection now treats local-only and name-only configurations more consistently.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Editor saves, malformed nested blocks, table rendering, math source editing, KaTeX radicals, and whiteboard permission errors were hardened before promotion.
|
||||
- Search keyboard navigation, quick-open note creation, favorite hover states, sort dropdown positioning, and full app note windows were stabilized.
|
||||
- Release automation now reuses artifact build workflows more cleanly and handles Windows signing/authenticode paths more defensively.
|
||||
- Packaged macOS MCP server paths, pending-change filenames with spaces, vault guidance edit status, and multi-vault autosync behavior were repaired.
|
||||
@@ -6,7 +6,7 @@ const repoRoot = path.resolve(import.meta.dirname, '..')
|
||||
const siteRoot = path.join(repoRoot, 'site')
|
||||
const outputRoot = path.join(repoRoot, 'src-tauri', 'resources', 'agent-docs')
|
||||
|
||||
const sectionOrder = ['start', 'concepts', 'guides', 'reference', 'troubleshooting', 'download', 'releases']
|
||||
const sectionOrder = ['start', 'concepts', 'guides', 'templates', 'reference', 'troubleshooting', 'download', 'releases']
|
||||
const ignoredDirs = new Set(['.vitepress', 'public', 'node_modules', '.DS_Store'])
|
||||
|
||||
function titleFromSlug(slug) {
|
||||
@@ -90,6 +90,8 @@ function buildIndex(docs) {
|
||||
'',
|
||||
'Start here, then use `rg` over this folder for specific Tolaria concepts and workflows.',
|
||||
'',
|
||||
'When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as Tolaria\'s default best-practice model. Read `pages/templates/portent.md` and combine it with the Tolaria concepts for types, relationships, properties, Inbox, archive, and custom views.',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const section of ['home', ...sectionOrder]) {
|
||||
@@ -120,11 +122,13 @@ This folder contains local, generated Tolaria product docs for AI agents.
|
||||
|
||||
Use these docs when a user asks how Tolaria works, when you need product behavior, or before making Tolaria-specific assumptions.
|
||||
|
||||
Portent is the default best-practice model for structuring Tolaria knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read \`pages/templates/portent.md\` and combine it with Tolaria's docs for types, relationships, properties, Inbox, archive, and custom views.
|
||||
|
||||
Recommended lookup flow:
|
||||
|
||||
1. Read the active vault's AGENTS.md for vault-specific conventions.
|
||||
2. Read this folder's index.md for the docs map.
|
||||
3. Use \`rg\` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, types, properties, relationships, and troubleshooting.
|
||||
3. Use \`rg\` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting.
|
||||
|
||||
Vault-specific AGENTS.md wins for local conventions. These bundled docs win for Tolaria product behavior.
|
||||
`
|
||||
|
||||
@@ -17,12 +17,16 @@ const hasMaxWorkersOverride = forwardedArgs.some((arg) =>
|
||||
)
|
||||
const maxAttempts = 2
|
||||
|
||||
// Standalone pnpm installs ship a native binary, so npm_execpath points at
|
||||
// a Mach-O/ELF executable. Only reuse process.execPath (node) when the path
|
||||
// is something node can actually load as a module.
|
||||
const packageManagerExec = process.env.npm_execpath
|
||||
const command = packageManagerExec ? process.execPath : 'pnpm'
|
||||
const baseCommandArgs = packageManagerExec
|
||||
const isJsExecpath = packageManagerExec && /\.[mc]?js$/i.test(packageManagerExec)
|
||||
const command = isJsExecpath ? process.execPath : 'pnpm'
|
||||
const baseCommandArgs = isJsExecpath
|
||||
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
|
||||
: ['exec', 'vitest', 'run', '--coverage']
|
||||
const clearCacheCommandArgs = packageManagerExec
|
||||
const clearCacheCommandArgs = isJsExecpath
|
||||
? [packageManagerExec, 'exec', 'vitest', '--clearCache']
|
||||
: ['exec', 'vitest', '--clearCache']
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default defineConfig({
|
||||
{ text: "Start", link: "/start/install" },
|
||||
{ text: "Concepts", link: "/concepts/vaults" },
|
||||
{ text: "Guides", link: "/guides/capture-a-note" },
|
||||
{ text: "Templates", link: "/templates/portent" },
|
||||
{ text: "Downloads", link: "https://tolaria.md/download/", target: "_self", noIcon: true },
|
||||
],
|
||||
search: {
|
||||
@@ -75,6 +76,12 @@ export default defineConfig({
|
||||
{ text: "Use The Command Palette", link: "/guides/use-command-palette" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Templates",
|
||||
items: [
|
||||
{ text: "Portent", link: "/templates/portent" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
items: [
|
||||
|
||||
@@ -6,7 +6,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
|
||||
@@ -23,11 +23,15 @@ brew install --cask tolaria
|
||||
| Platform | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
## Managed Windows Devices
|
||||
|
||||
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
|
||||
|
||||
## After Installing
|
||||
|
||||
1. Open Tolaria.
|
||||
|
||||
72
site/templates/portent.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Portent
|
||||
|
||||
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
|
||||
|
||||
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
|
||||
|
||||
## Core Questions
|
||||
|
||||
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
|
||||
|
||||
- What is this?
|
||||
- What is it useful for?
|
||||
- Is it captured, organized, or archived?
|
||||
|
||||
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
|
||||
|
||||
## Types
|
||||
|
||||
Portent defines eight default types.
|
||||
|
||||
PORT types are actionable:
|
||||
|
||||
- Project
|
||||
- Operation
|
||||
- Responsibility
|
||||
- Task
|
||||
|
||||
ENTP types are non-actionable knowledge records:
|
||||
|
||||
- Event
|
||||
- Note
|
||||
- Topic
|
||||
- Person
|
||||
|
||||
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
|
||||
|
||||
## Relationships
|
||||
|
||||
Portent models knowledge as a graph. The two default relationships are:
|
||||
|
||||
- `belongs_to`: primary ownership, composition, or context.
|
||||
- `related_to`: a looser semantic connection.
|
||||
|
||||
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Portent separates capture from organization:
|
||||
|
||||
1. Capture information quickly so it is not lost.
|
||||
2. Organize it by assigning a type and useful relationships.
|
||||
3. Archive it when it has served its purpose.
|
||||
|
||||
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
|
||||
|
||||
## Why Use It
|
||||
|
||||
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
|
||||
|
||||
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
|
||||
|
||||
## Start From The Template
|
||||
|
||||
The fastest starting point is the Portent template vault:
|
||||
|
||||
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
|
||||
|
||||
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
|
||||
|
||||
## Learn More
|
||||
|
||||
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
|
||||
185
src-tauri/Cargo.lock
generated
@@ -470,6 +470,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -628,6 +634,26 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -717,6 +743,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -892,7 +924,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -947,6 +979,15 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -1079,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1934,7 +1975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2051,6 +2092,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2469,6 +2523,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -2484,7 +2548,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -2955,6 +3019,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3250,6 +3324,19 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -3392,6 +3479,12 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -3453,7 +3546,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3840,6 +3933,16 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.40.0"
|
||||
@@ -3887,7 +3990,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3944,7 +4047,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4742,6 +4845,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4809,7 +4913,7 @@ dependencies = [
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -4856,6 +4960,27 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
@@ -4964,6 +5089,22 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.0"
|
||||
@@ -5108,7 +5249,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5195,6 +5336,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
@@ -5298,11 +5448,13 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-prevent-default",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -5517,7 +5669,7 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -6045,7 +6197,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6172,6 +6324,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
|
||||
@@ -21,7 +21,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
log = "0.4"
|
||||
notify = "6.1"
|
||||
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] }
|
||||
tauri = { version = "2.10.0", features = ["macos-private-api", "protocol-asset", "devtools", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
gray_matter = "0.2"
|
||||
walkdir = "2"
|
||||
@@ -40,5 +40,7 @@ sentry = "0.37"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tempfile = "3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main",
|
||||
"ai-workspace",
|
||||
"note-*"
|
||||
],
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
@@ -17,7 +18,9 @@
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:event:default",
|
||||
"dialog:default",
|
||||
"deep-link:default",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"opener:default"
|
||||
|
||||
BIN
src-tauri/icons/512x512-dark.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 113 KiB |
41
src-tauri/icons/icon-source-dark.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_110_75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="#151923"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="url(#paint0_linear_110_75)" fill-opacity="0.42"/>
|
||||
</g>
|
||||
<mask id="mask0_110_75" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="100" y="100" width="824" height="824">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_110_75)">
|
||||
</g>
|
||||
<g opacity="0.4" filter="url(#filter1_f_110_75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#155DFF"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#C4E4FF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#155DFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="url(#paint1_linear_110_75)"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_110_75" x="75" y="80" width="884" height="884" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="5" dy="10"/>
|
||||
<feGaussianBlur stdDeviation="15"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_110_75"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_110_75" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_110_75" x="281" y="208" width="461" height="607" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="15" result="effect1_foregroundBlur_110_75"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_110_75" x1="233" y1="138.5" x2="784.5" y2="1320.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2D3546"/>
|
||||
<stop offset="1" stop-color="#070A10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_110_75" x1="126" y1="-35.5" x2="723" y2="744" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00AEFF"/>
|
||||
<stop offset="1" stop-color="#155DFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -4,10 +4,12 @@ This folder contains local, generated Tolaria product docs for AI agents.
|
||||
|
||||
Use these docs when a user asks how Tolaria works, when you need product behavior, or before making Tolaria-specific assumptions.
|
||||
|
||||
Portent is the default best-practice model for structuring Tolaria knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read `pages/templates/portent.md` and combine it with Tolaria's docs for types, relationships, properties, Inbox, archive, and custom views.
|
||||
|
||||
Recommended lookup flow:
|
||||
|
||||
1. Read the active vault's AGENTS.md for vault-specific conventions.
|
||||
2. Read this folder's index.md for the docs map.
|
||||
3. Use `rg` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, types, properties, relationships, and troubleshooting.
|
||||
3. Use `rg` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting.
|
||||
|
||||
Vault-specific AGENTS.md wins for local conventions. These bundled docs win for Tolaria product behavior.
|
||||
|
||||
@@ -120,11 +120,15 @@ brew install --cask tolaria
|
||||
| Platform | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
## Managed Windows Devices
|
||||
|
||||
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
|
||||
|
||||
## After Installing
|
||||
|
||||
1. Open Tolaria.
|
||||
@@ -1023,6 +1027,86 @@ Prefer clear note titles and filenames. Tolaria's wikilink autocomplete helps yo
|
||||
|
||||
---
|
||||
|
||||
# Portent
|
||||
|
||||
Source: templates/portent.md
|
||||
URL: /templates/portent
|
||||
|
||||
# Portent
|
||||
|
||||
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
|
||||
|
||||
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
|
||||
|
||||
## Core Questions
|
||||
|
||||
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
|
||||
|
||||
- What is this?
|
||||
- What is it useful for?
|
||||
- Is it captured, organized, or archived?
|
||||
|
||||
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
|
||||
|
||||
## Types
|
||||
|
||||
Portent defines eight default types.
|
||||
|
||||
PORT types are actionable:
|
||||
|
||||
- Project
|
||||
- Operation
|
||||
- Responsibility
|
||||
- Task
|
||||
|
||||
ENTP types are non-actionable knowledge records:
|
||||
|
||||
- Event
|
||||
- Note
|
||||
- Topic
|
||||
- Person
|
||||
|
||||
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
|
||||
|
||||
## Relationships
|
||||
|
||||
Portent models knowledge as a graph. The two default relationships are:
|
||||
|
||||
- `belongs_to`: primary ownership, composition, or context.
|
||||
- `related_to`: a looser semantic connection.
|
||||
|
||||
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Portent separates capture from organization:
|
||||
|
||||
1. Capture information quickly so it is not lost.
|
||||
2. Organize it by assigning a type and useful relationships.
|
||||
3. Archive it when it has served its purpose.
|
||||
|
||||
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
|
||||
|
||||
## Why Use It
|
||||
|
||||
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
|
||||
|
||||
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
|
||||
|
||||
## Start From The Template
|
||||
|
||||
The fastest starting point is the Portent template vault:
|
||||
|
||||
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
|
||||
|
||||
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
|
||||
|
||||
## Learn More
|
||||
|
||||
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
|
||||
|
||||
---
|
||||
|
||||
# Contribute
|
||||
|
||||
Source: reference/contribute.md
|
||||
@@ -1272,7 +1356,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ These docs are generated from the public Tolaria documentation for local AI agen
|
||||
|
||||
Start here, then use `rg` over this folder for specific Tolaria concepts and workflows.
|
||||
|
||||
When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as Tolaria's default best-practice model. Read `pages/templates/portent.md` and combine it with the Tolaria concepts for types, relationships, properties, Inbox, archive, and custom views.
|
||||
|
||||
## Home
|
||||
|
||||
- [Index](pages/index.md)
|
||||
@@ -44,6 +46,10 @@ Start here, then use `rg` over this folder for specific Tolaria concepts and wor
|
||||
- [Use The Table Of Contents](pages/guides/use-table-of-contents.md)
|
||||
- [Use Wikilinks](pages/guides/use-wikilinks.md)
|
||||
|
||||
## Templates
|
||||
|
||||
- [Portent](pages/templates/portent.md)
|
||||
|
||||
## Reference
|
||||
|
||||
- [Contribute](pages/reference/contribute.md)
|
||||
|
||||
@@ -11,7 +11,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
|
||||
@@ -28,11 +28,15 @@ brew install --cask tolaria
|
||||
| Platform | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
## Managed Windows Devices
|
||||
|
||||
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
|
||||
|
||||
## After Installing
|
||||
|
||||
1. Open Tolaria.
|
||||
|
||||
77
src-tauri/resources/agent-docs/pages/templates/portent.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Portent
|
||||
|
||||
Source: templates/portent.md
|
||||
URL: /templates/portent
|
||||
|
||||
# Portent
|
||||
|
||||
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
|
||||
|
||||
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
|
||||
|
||||
## Core Questions
|
||||
|
||||
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
|
||||
|
||||
- What is this?
|
||||
- What is it useful for?
|
||||
- Is it captured, organized, or archived?
|
||||
|
||||
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
|
||||
|
||||
## Types
|
||||
|
||||
Portent defines eight default types.
|
||||
|
||||
PORT types are actionable:
|
||||
|
||||
- Project
|
||||
- Operation
|
||||
- Responsibility
|
||||
- Task
|
||||
|
||||
ENTP types are non-actionable knowledge records:
|
||||
|
||||
- Event
|
||||
- Note
|
||||
- Topic
|
||||
- Person
|
||||
|
||||
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
|
||||
|
||||
## Relationships
|
||||
|
||||
Portent models knowledge as a graph. The two default relationships are:
|
||||
|
||||
- `belongs_to`: primary ownership, composition, or context.
|
||||
- `related_to`: a looser semantic connection.
|
||||
|
||||
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Portent separates capture from organization:
|
||||
|
||||
1. Capture information quickly so it is not lost.
|
||||
2. Organize it by assigning a type and useful relationships.
|
||||
3. Archive it when it has served its purpose.
|
||||
|
||||
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
|
||||
|
||||
## Why Use It
|
||||
|
||||
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
|
||||
|
||||
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
|
||||
|
||||
## Start From The Template
|
||||
|
||||
The fastest starting point is the Portent template vault:
|
||||
|
||||
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
|
||||
|
||||
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
|
||||
|
||||
## Learn More
|
||||
|
||||
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
|
||||
@@ -247,7 +247,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"Download",
|
||||
"Homebrew",
|
||||
"Platform Status",
|
||||
"Managed Windows Devices",
|
||||
"After Installing"
|
||||
]
|
||||
},
|
||||
@@ -323,6 +324,21 @@
|
||||
"Keep Links Stable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Portent",
|
||||
"path": "pages/templates/portent.md",
|
||||
"url": "/templates/portent",
|
||||
"section": "templates",
|
||||
"headings": [
|
||||
"Core Questions",
|
||||
"Types",
|
||||
"Relationships",
|
||||
"Lifecycle",
|
||||
"Why Use It",
|
||||
"Start From The Template",
|
||||
"Learn More"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Contribute",
|
||||
"path": "pages/reference/contribute.md",
|
||||
|
||||
@@ -111,11 +111,15 @@ brew install --cask tolaria
|
||||
| Platform | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
## Managed Windows Devices
|
||||
|
||||
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
|
||||
|
||||
## After Installing
|
||||
|
||||
1. Open Tolaria.
|
||||
|
||||
77
src-tauri/resources/agent-docs/templates.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Portent
|
||||
|
||||
Source: templates/portent.md
|
||||
URL: /templates/portent
|
||||
|
||||
# Portent
|
||||
|
||||
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
|
||||
|
||||
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
|
||||
|
||||
## Core Questions
|
||||
|
||||
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
|
||||
|
||||
- What is this?
|
||||
- What is it useful for?
|
||||
- Is it captured, organized, or archived?
|
||||
|
||||
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
|
||||
|
||||
## Types
|
||||
|
||||
Portent defines eight default types.
|
||||
|
||||
PORT types are actionable:
|
||||
|
||||
- Project
|
||||
- Operation
|
||||
- Responsibility
|
||||
- Task
|
||||
|
||||
ENTP types are non-actionable knowledge records:
|
||||
|
||||
- Event
|
||||
- Note
|
||||
- Topic
|
||||
- Person
|
||||
|
||||
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
|
||||
|
||||
## Relationships
|
||||
|
||||
Portent models knowledge as a graph. The two default relationships are:
|
||||
|
||||
- `belongs_to`: primary ownership, composition, or context.
|
||||
- `related_to`: a looser semantic connection.
|
||||
|
||||
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Portent separates capture from organization:
|
||||
|
||||
1. Capture information quickly so it is not lost.
|
||||
2. Organize it by assigning a type and useful relationships.
|
||||
3. Archive it when it has served its purpose.
|
||||
|
||||
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
|
||||
|
||||
## Why Use It
|
||||
|
||||
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
|
||||
|
||||
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
|
||||
|
||||
## Start From The Template
|
||||
|
||||
The fastest starting point is the Portent template vault:
|
||||
|
||||
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
|
||||
|
||||
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
|
||||
|
||||
## Learn More
|
||||
|
||||
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
|
||||
@@ -8,6 +8,7 @@ pub enum AiAgentId {
|
||||
Opencode,
|
||||
Pi,
|
||||
Gemini,
|
||||
Kiro,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
@@ -31,6 +32,7 @@ pub struct AiAgentsStatus {
|
||||
pub opencode: AiAgentAvailability,
|
||||
pub pi: AiAgentAvailability,
|
||||
pub gemini: AiAgentAvailability,
|
||||
pub kiro: AiAgentAvailability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -71,6 +73,8 @@ pub struct AiAgentStreamRequest {
|
||||
#[serde(default)]
|
||||
pub vault_paths: Vec<String>,
|
||||
pub permission_mode: Option<AiAgentPermissionMode>,
|
||||
#[serde(default)]
|
||||
pub event_name: Option<String>,
|
||||
}
|
||||
|
||||
impl AiAgentStreamRequest {
|
||||
@@ -79,13 +83,44 @@ impl AiAgentStreamRequest {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
/// Probe every supported AI-agent CLI in parallel.
|
||||
///
|
||||
/// Each per-agent `check_cli()` is synchronous and can block for up to ~1 s
|
||||
/// when the binary is missing and we fall through to the login-shell
|
||||
/// fallback (`/bin/zsh -lc 'command -v <agent>'` etc., evaluating the full
|
||||
/// shell startup). Running them sequentially used to add ~5 s to cold start
|
||||
/// when no agents are installed. Fan them out across Tokio's blocking pool
|
||||
/// so the user-perceived wall time is the slowest single probe rather than
|
||||
/// the sum of all six.
|
||||
///
|
||||
/// A panicking probe is mapped to `installed: false` so the IPC handler
|
||||
/// always returns a fully populated `AiAgentsStatus` and the frontend can
|
||||
/// keep rendering.
|
||||
pub async fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
let claude = tokio::task::spawn_blocking(availability_from_claude);
|
||||
let codex = tokio::task::spawn_blocking(crate::codex_cli::check_cli);
|
||||
let opencode = tokio::task::spawn_blocking(crate::opencode_cli::check_cli);
|
||||
let pi = tokio::task::spawn_blocking(crate::pi_cli::check_cli);
|
||||
let gemini = tokio::task::spawn_blocking(crate::gemini_cli::check_cli);
|
||||
let kiro = tokio::task::spawn_blocking(crate::kiro_cli::check_cli);
|
||||
|
||||
let (claude, codex, opencode, pi, gemini, kiro) =
|
||||
tokio::join!(claude, codex, opencode, pi, gemini, kiro);
|
||||
|
||||
AiAgentsStatus {
|
||||
claude_code: availability_from_claude(),
|
||||
codex: crate::codex_cli::check_cli(),
|
||||
opencode: crate::opencode_cli::check_cli(),
|
||||
pi: crate::pi_cli::check_cli(),
|
||||
gemini: crate::gemini_cli::check_cli(),
|
||||
claude_code: claude.unwrap_or_else(|_| missing_availability()),
|
||||
codex: codex.unwrap_or_else(|_| missing_availability()),
|
||||
opencode: opencode.unwrap_or_else(|_| missing_availability()),
|
||||
pi: pi.unwrap_or_else(|_| missing_availability()),
|
||||
gemini: gemini.unwrap_or_else(|_| missing_availability()),
|
||||
kiro: kiro.unwrap_or_else(|_| missing_availability()),
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_availability() -> AiAgentAvailability {
|
||||
AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,9 +184,28 @@ where
|
||||
};
|
||||
crate::gemini_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Kiro => run_kiro_agent_stream(request, permission_mode, emit),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_kiro_agent_stream<F>(
|
||||
request: AiAgentStreamRequest,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let mapped = crate::cli_agent_runtime::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
vault_paths: request.vault_paths,
|
||||
permission_mode,
|
||||
};
|
||||
crate::kiro_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
|
||||
fn availability_from_claude() -> AiAgentAvailability {
|
||||
let status = crate::claude_cli::check_cli();
|
||||
AiAgentAvailability {
|
||||
@@ -208,6 +262,7 @@ mod tests {
|
||||
vault_path: "/tmp/vault".into(),
|
||||
vault_paths: Vec::new(),
|
||||
permission_mode,
|
||||
event_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,15 +278,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
#[tokio::test]
|
||||
async fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status().await;
|
||||
let install_flags = [
|
||||
status.claude_code.installed,
|
||||
status.codex.installed,
|
||||
status.opencode.installed,
|
||||
status.pi.installed,
|
||||
status.gemini.installed,
|
||||
status.kiro.installed,
|
||||
];
|
||||
|
||||
assert!(install_flags
|
||||
|
||||
@@ -62,6 +62,8 @@ pub struct AiModelStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub api_key_override: Option<String>,
|
||||
#[serde(default)]
|
||||
pub event_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -181,6 +183,7 @@ pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<Str
|
||||
.into(),
|
||||
),
|
||||
api_key_override: normalize_optional_string(request.api_key_override),
|
||||
event_name: None,
|
||||
};
|
||||
send_model_message(&request)
|
||||
}
|
||||
@@ -535,6 +538,7 @@ mod tests {
|
||||
message: "Hello".into(),
|
||||
system_prompt: Some(" Be concise. ".into()),
|
||||
api_key_override: None,
|
||||
event_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
59
src-tauri/src/app_icon.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use tauri::Manager;
|
||||
|
||||
const LIGHT_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512.png");
|
||||
const DARK_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512-dark.png");
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum AppIconMode {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl AppIconMode {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"light" => Ok(Self::Light),
|
||||
"dark" => Ok(Self::Dark),
|
||||
_ => Err(format!("Unsupported app icon theme mode: {value}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn png_bytes(self) -> &'static [u8] {
|
||||
match self {
|
||||
Self::Light => LIGHT_ICON_BYTES,
|
||||
Self::Dark => DARK_ICON_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_app_icon_for_theme(
|
||||
app_handle: &tauri::AppHandle,
|
||||
theme_mode: &str,
|
||||
) -> Result<(), String> {
|
||||
let icon_bytes = AppIconMode::parse(theme_mode)?.png_bytes();
|
||||
let image = tauri::image::Image::from_bytes(icon_bytes)
|
||||
.map_err(|err| format!("Failed to decode app icon: {err}"))?;
|
||||
|
||||
for window in app_handle.webview_windows().into_values() {
|
||||
window
|
||||
.set_icon(image.clone())
|
||||
.map_err(|err| format!("Failed to update window icon: {err}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppIconMode;
|
||||
|
||||
#[test]
|
||||
fn parses_supported_icon_modes() {
|
||||
assert_eq!(AppIconMode::parse("light"), Ok(AppIconMode::Light));
|
||||
assert_eq!(AppIconMode::parse("dark"), Ok(AppIconMode::Dark));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_icon_modes() {
|
||||
assert!(AppIconMode::parse("system").is_err());
|
||||
}
|
||||
}
|
||||
@@ -118,20 +118,33 @@ fn claude_path_from_shell(shell: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
first_existing_path_for_platform(stdout, cfg!(windows))
|
||||
}
|
||||
|
||||
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
|
||||
let mut paths = stdout.lines().filter_map(existing_path);
|
||||
if windows {
|
||||
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
paths.next()
|
||||
}
|
||||
|
||||
fn existing_path(line: &str) -> Option<PathBuf> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
}
|
||||
|
||||
fn claude_binary_candidates() -> Vec<PathBuf> {
|
||||
@@ -710,6 +723,22 @@ mod tests {
|
||||
assert!(candidates.contains(&claude), "missing {}", claude.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shell_script = dir.path().join("claude");
|
||||
let cmd_shim = dir.path().join("claude.cmd");
|
||||
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
|
||||
|
||||
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
|
||||
|
||||
assert_eq!(
|
||||
first_existing_path_for_platform(&stdout, true),
|
||||
Some(cmd_shim)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_claude_flag_errors_are_detected() {
|
||||
assert!(is_unsupported_claude_flag_error(ClaudeStderr(
|
||||
|
||||
@@ -455,6 +455,97 @@ where
|
||||
Ok(run.session_id)
|
||||
}
|
||||
|
||||
/// Shared binary discovery: look up a CLI command by name using PATH, login shell, then candidates.
|
||||
pub(crate) fn find_cli_binary(
|
||||
name: &str,
|
||||
candidates: Vec<PathBuf>,
|
||||
label: &str,
|
||||
install_hint: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_binary_on_path(name) {
|
||||
return Ok(binary);
|
||||
}
|
||||
if let Some(binary) = find_binary_in_user_shell(name) {
|
||||
return Ok(binary);
|
||||
}
|
||||
if let Some(binary) = find_executable_binary_candidate(candidates, label)? {
|
||||
return Ok(binary);
|
||||
}
|
||||
Err(format!("{label} not found. Install it: {install_hint}"))
|
||||
}
|
||||
|
||||
fn find_binary_on_path(name: &str) -> Option<PathBuf> {
|
||||
let cmd = if cfg!(windows) { "where" } else { "which" };
|
||||
crate::hidden_command(cmd)
|
||||
.arg(name)
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell(name: &str) -> Option<PathBuf> {
|
||||
shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, name))
|
||||
}
|
||||
|
||||
fn shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared check_cli pattern for CLI agents.
|
||||
pub(crate) fn check_cli_availability(
|
||||
find_binary: impl FnOnce() -> Result<PathBuf, String>,
|
||||
) -> crate::ai_agents::AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => crate::ai_agents::AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4,20 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
let binary = match find_codex_binary() {
|
||||
Ok(binary) => binary,
|
||||
Err(_) => {
|
||||
return AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
}
|
||||
codex_availability_from_binary_result(find_codex_binary())
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
@@ -29,15 +16,37 @@ where
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
find_codex_binary_on_path()
|
||||
.filter(|binary| is_usable_codex_binary(binary))
|
||||
.or_else(|| {
|
||||
find_codex_binary_in_user_shell().filter(|binary| is_usable_codex_binary(binary))
|
||||
})
|
||||
.or_else(|| find_usable_codex_binary(codex_binary_candidates()))
|
||||
.ok_or_else(|| {
|
||||
"Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()
|
||||
})
|
||||
if let Some(binary) = find_codex_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_codex_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate(
|
||||
codex_binary_candidates(),
|
||||
"Codex CLI",
|
||||
)? {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn codex_availability_from_binary_result(
|
||||
binary_result: Result<PathBuf, String>,
|
||||
) -> AiAgentAvailability {
|
||||
match binary_result {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
@@ -60,7 +69,7 @@ fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
||||
.find_map(|shell| codex_path_from_shell(&shell))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
@@ -75,10 +84,10 @@ fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
fn codex_path_from_shell(shell: &Path) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.arg("command -v codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
@@ -93,14 +102,25 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
first_existing_path_for_platform(stdout, cfg!(windows))
|
||||
}
|
||||
|
||||
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
|
||||
let mut paths = stdout.lines().filter_map(existing_path);
|
||||
if windows {
|
||||
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
|
||||
}
|
||||
|
||||
paths.next()
|
||||
}
|
||||
|
||||
fn existing_path(line: &str) -> Option<PathBuf> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
}
|
||||
|
||||
fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
@@ -113,12 +133,16 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".local/bin/codex.cmd"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".codex/bin/codex.exe"),
|
||||
home.join(".codex/bin/codex.cmd"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex.cmd"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
@@ -127,22 +151,24 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex"),
|
||||
home.join(".bun/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex.cmd"),
|
||||
home.join(".linuxbrew/bin/codex"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.cmd"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
];
|
||||
candidates.extend(nvm_node_binary_candidates_for_home(home, "codex"));
|
||||
candidates.extend(nvm_codex_binary_candidates_for_home(home));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
|
||||
fn nvm_codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -151,22 +177,12 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<Pa
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.map(|path| path.join("bin").join(binary_name))
|
||||
.map(|path| path.join("bin").join("codex"))
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_usable_codex_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|binary| is_usable_codex_binary(binary))
|
||||
}
|
||||
|
||||
fn is_usable_codex_binary(binary: &Path) -> bool {
|
||||
crate::cli_agent_runtime::version_for_binary(binary).is_some()
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
@@ -191,7 +207,12 @@ where
|
||||
emit,
|
||||
codex_session_id,
|
||||
dispatch_codex_event,
|
||||
format_codex_error,
|
||||
|stderr_output, status| {
|
||||
format_codex_error(CodexProcessError {
|
||||
stderr_output,
|
||||
status,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -265,7 +286,7 @@ fn build_codex_args(
|
||||
"-c".into(),
|
||||
codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]),
|
||||
"-c".into(),
|
||||
codex_mcp_env_config(&request.vault_path, &request.vault_paths),
|
||||
codex_mcp_env_config(request),
|
||||
];
|
||||
|
||||
if let Some(path) = last_message_path {
|
||||
@@ -289,11 +310,14 @@ fn codex_config_string_list(key: &str, values: &[&str]) -> String {
|
||||
format!("{key}=[{values}]")
|
||||
}
|
||||
|
||||
fn codex_mcp_env_config(vault_path: &str, vault_paths: &[String]) -> String {
|
||||
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths);
|
||||
fn codex_mcp_env_config(request: &AgentStreamRequest) -> String {
|
||||
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(
|
||||
&request.vault_path,
|
||||
&request.vault_paths,
|
||||
);
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",VAULT_PATHS="{}",WS_UI_PORT="9711"}}"#,
|
||||
toml_escape(vault_path),
|
||||
toml_escape(&request.vault_path),
|
||||
toml_escape(&vault_paths)
|
||||
)
|
||||
}
|
||||
@@ -431,8 +455,13 @@ fn read_codex_last_message(path: &Path) -> Option<String> {
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
struct CodexProcessError {
|
||||
stderr_output: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
fn format_codex_error(error: CodexProcessError) -> String {
|
||||
let lower = error.stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
@@ -441,10 +470,15 @@ fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or verify the selected vault folder is writable and retry. Writes outside the active vault remain blocked.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {status}")
|
||||
if error.stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {}", error.status)
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
error
|
||||
.stderr_output
|
||||
.lines()
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,16 +979,22 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".local/bin/codex.cmd"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex.cmd"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex.cmd"),
|
||||
home.join(".codex/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
home.join(".npm/bin/codex.cmd"),
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.cmd"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
];
|
||||
|
||||
@@ -967,6 +1007,16 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_availability_reports_installed_even_when_version_probe_fails() {
|
||||
let binary = PathBuf::from("C:/Users/alex/AppData/Roaming/npm/codex.cmd");
|
||||
|
||||
let availability = codex_availability_from_binary_result(Ok(binary));
|
||||
|
||||
assert!(availability.installed);
|
||||
assert_eq!(availability.version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
@@ -979,18 +1029,6 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
assert!(candidates.contains(&codex), "missing {}", codex.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn usable_codex_binary_skips_broken_shims() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let broken = executable_script(dir.path(), "broken-codex", "exit 1\n");
|
||||
let working = executable_script(dir.path(), "codex", "echo codex-cli 0.124.0-alpha.2\n");
|
||||
|
||||
let found = find_usable_codex_binary(vec![broken, working.clone()]);
|
||||
|
||||
assert_eq!(found, Some(working));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -1003,6 +1041,22 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
assert_eq!(first_existing_path(&stdout), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shell_script = dir.path().join("codex");
|
||||
let cmd_shim = dir.path().join("codex.cmd");
|
||||
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
|
||||
|
||||
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
|
||||
|
||||
assert_eq!(
|
||||
first_existing_path_for_platform(&stdout, true),
|
||||
Some(cmd_shim)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_codex_from_login_shell() {
|
||||
@@ -1024,7 +1078,7 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
|
||||
assert_eq!(codex_path_from_shell(&shell), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1129,10 +1183,10 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
|
||||
#[test]
|
||||
fn format_codex_error_explains_vault_write_permission_failures() {
|
||||
let message = format_codex_error(
|
||||
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
let message = format_codex_error(CodexProcessError {
|
||||
stderr_output: "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
|
||||
status: "exit status: 1".into(),
|
||||
});
|
||||
|
||||
assert!(message.contains("active vault"));
|
||||
assert!(message.contains("writable"));
|
||||
|
||||
@@ -16,7 +16,7 @@ const AGENT_DOCS_RESOURCE_DIR: &str = "agent-docs";
|
||||
#[cfg(desktop)]
|
||||
async fn run_desktop_stream<Event, Request, Runner>(
|
||||
app_handle: tauri::AppHandle,
|
||||
event_name: &'static str,
|
||||
event_name: String,
|
||||
request: Request,
|
||||
runner: Runner,
|
||||
) -> Result<String, String>
|
||||
@@ -31,7 +31,7 @@ where
|
||||
runner(
|
||||
request,
|
||||
Box::new(move |event| {
|
||||
let _ = app_handle.emit(event_name, &event);
|
||||
let _ = app_handle.emit(event_name.as_str(), &event);
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -47,11 +47,32 @@ macro_rules! define_desktop_stream_command {
|
||||
app_handle: tauri::AppHandle,
|
||||
request: $request,
|
||||
) -> Result<String, String> {
|
||||
run_desktop_stream(app_handle, $event_name, request, $runner).await
|
||||
run_desktop_stream(app_handle, $event_name.to_string(), request, $runner).await
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn is_scoped_stream_event_name(default_event_name: &str, event_name: &str) -> bool {
|
||||
event_name
|
||||
.strip_prefix(default_event_name)
|
||||
.and_then(|suffix| suffix.strip_prefix('-'))
|
||||
.is_some_and(|suffix| {
|
||||
!suffix.is_empty()
|
||||
&& suffix
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '-')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn stream_event_name(default_event_name: &'static str, requested: Option<&str>) -> String {
|
||||
requested
|
||||
.filter(|event_name| is_scoped_stream_event_name(default_event_name, event_name))
|
||||
.unwrap_or(default_event_name)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ── Claude CLI commands (desktop) ───────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -62,8 +83,8 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
crate::ai_agents::get_ai_agents_status()
|
||||
pub async fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
crate::ai_agents::get_ai_agents_status().await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -135,20 +156,36 @@ fn run_normalized_ai_agent_stream(
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_agent,
|
||||
AiAgentStreamRequest,
|
||||
"ai-agent-stream",
|
||||
run_normalized_ai_agent_stream
|
||||
);
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AiAgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
let event_name = stream_event_name("ai-agent-stream", request.event_name.as_deref());
|
||||
run_desktop_stream(
|
||||
app_handle,
|
||||
event_name,
|
||||
request,
|
||||
run_normalized_ai_agent_stream,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_model,
|
||||
AiModelStreamRequest,
|
||||
"ai-model-stream",
|
||||
crate::ai_models::run_ai_model_stream
|
||||
);
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_model(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AiModelStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
let event_name = stream_event_name("ai-model-stream", request.event_name.as_deref());
|
||||
run_desktop_stream(
|
||||
app_handle,
|
||||
event_name,
|
||||
request,
|
||||
crate::ai_models::run_ai_model_stream,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
@@ -280,6 +317,7 @@ mod tests {
|
||||
vault_path: "~/Vaults/content".into(),
|
||||
vault_paths: vec!["~/Vaults/secondary".into()],
|
||||
permission_mode: None,
|
||||
event_name: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_agent_request(request);
|
||||
@@ -308,6 +346,7 @@ mod tests {
|
||||
vault_path: "/Users/example/vault".into(),
|
||||
vault_paths: Vec::new(),
|
||||
permission_mode: None,
|
||||
event_name: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_agent_request(request);
|
||||
@@ -315,6 +354,23 @@ mod tests {
|
||||
assert_eq!(normalized.vault_path, "/Users/example/vault");
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn stream_event_name_accepts_only_scoped_names() {
|
||||
assert_eq!(
|
||||
stream_event_name("ai-agent-stream", Some("ai-agent-stream-chat-123")),
|
||||
"ai-agent-stream-chat-123",
|
||||
);
|
||||
assert_eq!(
|
||||
stream_event_name("ai-agent-stream", Some("ai-model-stream-chat-123")),
|
||||
"ai-agent-stream",
|
||||
);
|
||||
assert_eq!(
|
||||
stream_event_name("ai-agent-stream", Some("ai-agent-stream/../bad")),
|
||||
"ai-agent-stream",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guidance_commands_report_and_restore_vault_guidance_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
11
src-tauri/src/commands/app_icon.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn update_app_icon(app_handle: tauri::AppHandle, theme_mode: String) -> Result<(), String> {
|
||||
crate::app_icon::update_app_icon_for_theme(&app_handle, &theme_mode)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn update_app_icon(_theme_mode: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
207
src-tauri/src/commands/clipboard.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
#[cfg(desktop)]
|
||||
use std::io::Write;
|
||||
#[cfg(desktop)]
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
#[cfg(desktop)]
|
||||
use std::thread;
|
||||
#[cfg(desktop)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(desktop)]
|
||||
const NATIVE_CLIPBOARD_COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
#[cfg(desktop)]
|
||||
const NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("pbcopy")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
crate::hidden_command("pbpaste")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("clip.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("powershell.exe");
|
||||
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_failure_message(stderr: &[u8]) -> String {
|
||||
let message = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
if message.is_empty() {
|
||||
"Native clipboard command failed".to_string()
|
||||
} else {
|
||||
format!("Native clipboard command failed: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_timeout_message(timeout: Duration) -> String {
|
||||
format!(
|
||||
"Native clipboard command timed out after {}ms",
|
||||
timeout.as_millis()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn wait_for_native_clipboard_output(mut child: Child, timeout: Duration) -> Result<Output, String> {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_status)) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Native clipboard command did not finish: {e}"));
|
||||
}
|
||||
Ok(None) if started.elapsed() >= timeout => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(clipboard_timeout_message(timeout));
|
||||
}
|
||||
Ok(None) => thread::sleep(NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL),
|
||||
Err(e) => return Err(format!("Native clipboard command did not finish: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard_with_timeout(
|
||||
mut command: Command,
|
||||
text: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<(), String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
|
||||
drop(stdin);
|
||||
|
||||
let output = wait_for_native_clipboard_output(child, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard(command: Command, text: &str) -> Result<(), String> {
|
||||
write_native_clipboard_with_timeout(command, text, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard_with_timeout(
|
||||
mut command: Command,
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let child = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
|
||||
|
||||
let output = wait_for_native_clipboard_output(child, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard(command: Command) -> Result<String, String> {
|
||||
read_native_clipboard_with_timeout(command, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || write_native_clipboard(clipboard_command(), &text))
|
||||
.await
|
||||
.map_err(|e| format!("Native clipboard task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn read_text_from_clipboard() -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || read_native_clipboard(clipboard_read_command()))
|
||||
.await
|
||||
.map_err(|e| format!("Native clipboard task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn read_text_from_clipboard() -> Result<String, String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
#[test]
|
||||
fn native_clipboard_write_times_out_slow_commands() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "cat >/dev/null; sleep 2"]);
|
||||
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
write_native_clipboard_with_timeout(command, "copy me", Duration::from_millis(50));
|
||||
|
||||
let error = result.expect_err("slow clipboard command should time out");
|
||||
assert!(
|
||||
error.contains("timed out"),
|
||||
"unexpected clipboard timeout error: {error}"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"clipboard timeout should return promptly"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -475,7 +475,7 @@ mod tests {
|
||||
assert_eq!(pull.status, "no_remote");
|
||||
|
||||
let push = git_push(vault.clone()).await.unwrap();
|
||||
assert_eq!(push.status, "error");
|
||||
assert_eq!(push.status, "no_remote");
|
||||
|
||||
let status = git_remote_status(vault.clone()).await.unwrap();
|
||||
assert!(!status.has_remote);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
mod ai;
|
||||
mod app_icon;
|
||||
mod clipboard;
|
||||
mod delete;
|
||||
mod folders;
|
||||
mod git;
|
||||
pub mod git_clone;
|
||||
mod git_connect;
|
||||
mod memory;
|
||||
mod runtime;
|
||||
mod system;
|
||||
mod vault;
|
||||
mod version;
|
||||
@@ -12,11 +15,14 @@ mod version;
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub use ai::*;
|
||||
pub use app_icon::*;
|
||||
pub use clipboard::*;
|
||||
pub use delete::*;
|
||||
pub use folders::*;
|
||||
pub use git::*;
|
||||
pub use git_connect::*;
|
||||
pub use memory::*;
|
||||
pub use runtime::*;
|
||||
pub use system::*;
|
||||
pub use vault::*;
|
||||
pub use version::*;
|
||||
|
||||
48
src-tauri/src/commands/runtime.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
fn should_use_external_media_preview_for_appimage(is_linux_appimage: bool) -> bool {
|
||||
is_linux_appimage
|
||||
}
|
||||
|
||||
fn map_print_result<E: std::fmt::Display>(result: Result<(), E>) -> Result<(), String> {
|
||||
result.map_err(|error| format!("Failed to open the system print dialog: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn linux_appimage_running() -> bool {
|
||||
crate::linux_appimage::is_running()
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn linux_appimage_running() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn should_use_external_media_preview() -> bool {
|
||||
should_use_external_media_preview_for_appimage(linux_appimage_running())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn print_current_webview(window: tauri::WebviewWindow) -> Result<(), String> {
|
||||
map_print_result(window.print())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{map_print_result, should_use_external_media_preview_for_appimage};
|
||||
|
||||
#[test]
|
||||
fn external_media_preview_is_limited_to_linux_appimage() {
|
||||
assert!(should_use_external_media_preview_for_appimage(true));
|
||||
assert!(!should_use_external_media_preview_for_appimage(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_errors_are_formatted_for_the_renderer() {
|
||||
let result = map_print_result::<&str>(Err("printer unavailable"));
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err("Failed to open the system print dialog: printer unavailable".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
#[cfg(desktop)]
|
||||
use std::io::Write;
|
||||
#[cfg(desktop)]
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use crate::menu;
|
||||
@@ -142,113 +140,6 @@ pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String
|
||||
.map_err(|e| format!("MCP config task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("pbcopy")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
crate::hidden_command("pbpaste")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("clip.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("powershell.exe");
|
||||
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_failure_message(stderr: &[u8]) -> String {
|
||||
let message = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
if message.is_empty() {
|
||||
"Native clipboard command failed".to_string()
|
||||
} else {
|
||||
format!("Native clipboard command failed: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
|
||||
drop(stdin);
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Native clipboard command did not finish: {e}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard(mut command: Command) -> Result<String, String> {
|
||||
let output = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
write_native_clipboard(clipboard_command(), &text)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn read_text_from_clipboard() -> Result<String, String> {
|
||||
read_native_clipboard(clipboard_read_command())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
@@ -299,18 +190,6 @@ pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, Strin
|
||||
Err("MCP is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn read_text_from_clipboard() -> Result<String, String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
@@ -504,6 +383,16 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
crate::settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_ai_workspace_sessions() -> Result<serde_json::Value, String> {
|
||||
crate::settings::get_ai_workspace_sessions()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_ai_workspace_sessions(sessions: serde_json::Value) -> Result<(), String> {
|
||||
crate::settings::save_ai_workspace_sessions(sessions)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_update(
|
||||
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
fn test_create_vault_folder_rejects_escape_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let err = create_vault_folder(dir.path().into(), "../escape".into())
|
||||
let err = create_vault_folder(dir.path().into(), "../escape".into(), None)
|
||||
.expect_err("expected escaping folder path to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
@@ -202,12 +202,57 @@ mod tests {
|
||||
fn test_create_vault_folder_rejects_windows_invalid_names() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let err = create_vault_folder(dir.path().into(), "con".into())
|
||||
let err = create_vault_folder(dir.path().into(), "con".into(), None)
|
||||
.expect_err("expected Windows-invalid folder name to be rejected");
|
||||
|
||||
assert_eq!(err, "Invalid folder name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_folder_nests_inside_parent_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("Projects")).unwrap();
|
||||
|
||||
let name = create_vault_folder(
|
||||
dir.path().into(),
|
||||
"Laputa".into(),
|
||||
Some(std::path::PathBuf::from("Projects")),
|
||||
)
|
||||
.expect("expected nested folder to be created");
|
||||
|
||||
assert_eq!(name, "Laputa");
|
||||
assert!(dir.path().join("Projects").join("Laputa").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_folder_rejects_escape_via_parent_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let err = create_vault_folder(
|
||||
dir.path().into(),
|
||||
"Laputa".into(),
|
||||
Some(std::path::PathBuf::from("../escape")),
|
||||
)
|
||||
.expect_err("expected escaping parent path to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_folder_treats_empty_parent_as_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let name = create_vault_folder(
|
||||
dir.path().into(),
|
||||
"Inbox".into(),
|
||||
Some(std::path::PathBuf::from("")),
|
||||
)
|
||||
.expect("expected empty parent to fall back to vault root");
|
||||
|
||||
assert_eq!(name, "Inbox");
|
||||
assert!(dir.path().join("Inbox").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_view_cmd_rejects_nested_filename() {
|
||||
assert_save_view_cmd_rejects_invalid_filename("../escape.yml");
|
||||
|
||||
@@ -191,11 +191,19 @@ pub fn batch_delete_notes(paths: Vec<PathBuf>) -> Result<Vec<String>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<String, String> {
|
||||
pub fn create_vault_folder(
|
||||
vault_path: PathBuf,
|
||||
folder_name: PathBuf,
|
||||
parent_path: Option<PathBuf>,
|
||||
) -> Result<String, String> {
|
||||
let raw_vault_path = vault_path.to_string_lossy();
|
||||
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
|
||||
let folder_name = folder_name.to_string_lossy();
|
||||
let folder_path = boundary.child_path(folder_name.as_ref())?;
|
||||
let relative_path = match parent_path.as_deref() {
|
||||
Some(parent) if !parent.as_os_str().is_empty() => parent.join(folder_name.as_ref()),
|
||||
_ => PathBuf::from(folder_name.as_ref()),
|
||||
};
|
||||
let folder_path = boundary.child_path(&relative_path.to_string_lossy())?;
|
||||
validate_folder_name(folder_name.as_ref())?;
|
||||
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
|
||||
std::fs::create_dir_all(&folder_path)
|
||||
@@ -370,7 +378,7 @@ mod tests {
|
||||
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
create_vault_folder(root.clone(), PathBuf::from("Projects")).unwrap(),
|
||||
create_vault_folder(root.clone(), PathBuf::from("Projects"), None).unwrap(),
|
||||
"Projects"
|
||||
);
|
||||
fs::write(dir.path().join("Projects/project.md"), "# Project\n").unwrap();
|
||||
@@ -394,7 +402,7 @@ mod tests {
|
||||
assert!(error.contains("Path must stay inside the active vault"));
|
||||
|
||||
let folder_error =
|
||||
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"))
|
||||
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"), None)
|
||||
.unwrap_err();
|
||||
assert!(folder_error.contains("Path must stay inside the active vault"));
|
||||
}
|
||||
|
||||
@@ -100,14 +100,24 @@ pub async fn reload_vault(
|
||||
pub async fn search_vault(
|
||||
vault_path: String,
|
||||
query: String,
|
||||
mode: String,
|
||||
limit: Option<usize>,
|
||||
exclude_frontmatter: Option<bool>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
let limit = limit.unwrap_or(20);
|
||||
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
let exclude_frontmatter = exclude_frontmatter.unwrap_or(false);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
search::search_vault_with_options(search::SearchOptions {
|
||||
vault_path: &vault_path,
|
||||
query: &query,
|
||||
mode: "keyword",
|
||||
limit,
|
||||
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
|
||||
exclude_frontmatter,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -264,7 +274,7 @@ mod tests {
|
||||
let response = search_vault(
|
||||
dir.path().to_string_lossy().into_owned(),
|
||||
"needle".to_string(),
|
||||
"keyword".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -287,8 +297,8 @@ mod tests {
|
||||
let response = search_vault(
|
||||
dir.path().to_string_lossy().into_owned(),
|
||||
"needle".to_string(),
|
||||
"keyword".to_string(),
|
||||
Some(1),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
57
src-tauri/src/git/command.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::process::Output;
|
||||
|
||||
use super::git_command;
|
||||
|
||||
pub(super) fn git_output(dir: &Path, args: &[&str]) -> io::Result<Output> {
|
||||
git_command().args(args).current_dir(dir).output()
|
||||
}
|
||||
|
||||
pub(super) fn git_output_result(dir: &Path, args: &[&str]) -> Result<Output, String> {
|
||||
git_output(dir, args).map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))
|
||||
}
|
||||
|
||||
pub(super) fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = git_output_result(dir, args)?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(stderr_text(&output))
|
||||
}
|
||||
|
||||
pub(super) fn stdout_text(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
pub(super) fn stdout_lines(output: &Output) -> Vec<String> {
|
||||
stdout_text(output)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn stderr_text(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string()
|
||||
}
|
||||
|
||||
pub(super) fn stderr_or_failure(command: &str, output: &Output) -> String {
|
||||
let stderr = stderr_text(output);
|
||||
if stderr.is_empty() {
|
||||
format!("{command} failed")
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
|
||||
if args.first() == Some(&"-c") {
|
||||
return args.get(2).copied().unwrap_or(args[0]);
|
||||
}
|
||||
|
||||
args[0]
|
||||
}
|
||||
@@ -2,8 +2,12 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Output;
|
||||
|
||||
use super::command::{
|
||||
git_output, git_output_result, run_git, stderr_text, stdout_lines, stdout_text,
|
||||
};
|
||||
use super::credentials::request_remote_credentials;
|
||||
use super::{ensure_author_config, git_command};
|
||||
use super::ensure_author_config;
|
||||
use super::remote_config::{configure_origin_remote, list_configured_remotes};
|
||||
|
||||
const DEFAULT_REMOTE_NAME: &str = "origin";
|
||||
|
||||
@@ -105,7 +109,7 @@ pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemote
|
||||
let connection = RemoteConnection::new(branch);
|
||||
|
||||
let trimmed_url = remote_url.trim();
|
||||
run_git(vault, &["remote", "add", DEFAULT_REMOTE_NAME, trimmed_url])?;
|
||||
configure_origin_remote(vault, trimmed_url)?;
|
||||
request_remote_credentials(vault, trimmed_url);
|
||||
|
||||
let result = finish_remote_connection(vault, &connection);
|
||||
@@ -176,7 +180,7 @@ fn connect_result(status: ConnectStatus, message: impl Into<String>) -> GitAddRe
|
||||
}
|
||||
|
||||
fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
let output = git_output(vault, &["branch", "--show-current"])?;
|
||||
let output = git_output_result(vault, &["branch", "--show-current"])?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(stdout_text(&output));
|
||||
@@ -186,34 +190,11 @@ fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
}
|
||||
|
||||
fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
|
||||
let output = git_output(vault, &["remote"])?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(command_error("git remote", &output));
|
||||
}
|
||||
|
||||
Ok(stdout_lines(&output))
|
||||
list_configured_remotes(vault)
|
||||
}
|
||||
|
||||
fn unset_upstream(vault: &Path) {
|
||||
let _ = git_command()
|
||||
.args(["branch", "--unset-upstream"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
}
|
||||
|
||||
fn run_git(vault: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = git_command()
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git {}: {e}", args[0]))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
||||
let _ = git_output(vault, &["branch", "--unset-upstream"]);
|
||||
}
|
||||
|
||||
fn fetch_remote(vault: &Path) -> Result<(), String> {
|
||||
@@ -221,7 +202,7 @@ fn fetch_remote(vault: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
|
||||
let output = git_output(
|
||||
let output = git_output_result(
|
||||
vault,
|
||||
&[
|
||||
"for-each-ref",
|
||||
@@ -241,17 +222,17 @@ fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
|
||||
}
|
||||
|
||||
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
|
||||
git_command()
|
||||
.args(["merge-base", "HEAD", connection.remote_branch.as_str()])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
git_output(
|
||||
vault,
|
||||
&["merge-base", "HEAD", connection.remote_branch.as_str()],
|
||||
)
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn ahead_behind_counts(vault: &Path, connection: &RemoteConnection) -> Result<(u32, u32), String> {
|
||||
let revision_range = format!("HEAD...{}", connection.remote_branch);
|
||||
let output = git_output(
|
||||
let output = git_output_result(
|
||||
vault,
|
||||
&["rev-list", "--left-right", "--count", &revision_range],
|
||||
)?;
|
||||
@@ -318,35 +299,10 @@ fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
|
||||
)
|
||||
}
|
||||
|
||||
fn git_output(vault: &Path, args: &[&str]) -> Result<Output, String> {
|
||||
git_command()
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git {}: {e}", args[0]))
|
||||
}
|
||||
|
||||
fn command_error(command: &str, output: &Output) -> String {
|
||||
format!("{command} failed: {}", stderr_text(output))
|
||||
}
|
||||
|
||||
fn stderr_text(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string()
|
||||
}
|
||||
|
||||
fn stdout_text(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn stdout_lines(output: &Output) -> Vec<String> {
|
||||
stdout_text(output)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_auth_error(lower: &str) -> bool {
|
||||
[
|
||||
"authentication failed",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod clone;
|
||||
mod command;
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod connect;
|
||||
@@ -7,6 +8,7 @@ mod dates;
|
||||
mod history;
|
||||
mod pulse;
|
||||
mod remote;
|
||||
mod remote_config;
|
||||
mod status;
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
@@ -267,11 +269,12 @@ fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> {
|
||||
|
||||
/// Run a git command in the given directory, returning an error on failure.
|
||||
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = git_command()
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))?;
|
||||
let output = command::git_output(dir, args).map_err(|e| {
|
||||
format!(
|
||||
"Failed to run git {}: {e}",
|
||||
command::git_command_label(args)
|
||||
)
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
@@ -279,19 +282,11 @@ fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
|
||||
Err(format!(
|
||||
"git {} failed: {}",
|
||||
git_command_label(args),
|
||||
command::git_command_label(args),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
}
|
||||
|
||||
fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
|
||||
if args.first() == Some(&"-c") {
|
||||
return args.get(2).copied().unwrap_or(args[0]);
|
||||
}
|
||||
|
||||
args[0]
|
||||
}
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [("user.name", "Tolaria"), ("user.email", "vault@tolaria.md")] {
|
||||
@@ -644,6 +639,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_variants() {
|
||||
let tokenized_url = format!(
|
||||
"https://{}@github.com/owner/repo.git",
|
||||
["gho", "abc123"].join("_")
|
||||
);
|
||||
for url in [
|
||||
"https://github.com/owner/repo.git",
|
||||
"https://github.com/owner/repo",
|
||||
@@ -651,7 +650,7 @@ mod tests {
|
||||
"git@github.com:owner/repo.git",
|
||||
"git@github.com:owner/repo",
|
||||
"ssh://git@github.com/owner/repo.git",
|
||||
"https://gho_abc123@github.com/owner/repo.git",
|
||||
tokenized_url.as_str(),
|
||||
] {
|
||||
assert_repo_path(url, Some("owner/repo"));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use super::git_command;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use super::command::{git_output, git_output_result, stderr_text, stdout_text};
|
||||
use super::conflict::get_conflict_files;
|
||||
use super::remote_config::has_configured_remote;
|
||||
|
||||
const NO_REMOTE_STATUS: &str = "no_remote";
|
||||
const NO_REMOTE_MESSAGE: &str = "No remote configured";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPullResult {
|
||||
@@ -17,13 +21,7 @@ pub struct GitPullResult {
|
||||
/// Check whether the vault repo has at least one remote configured.
|
||||
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = git_command()
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git remote: {}", e))?;
|
||||
|
||||
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
|
||||
has_configured_remote(vault)
|
||||
}
|
||||
|
||||
/// Pull latest changes from remote. Uses --no-rebase to merge.
|
||||
@@ -33,21 +31,18 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
|
||||
|
||||
if !has_remote(vault_path)? {
|
||||
return Ok(GitPullResult {
|
||||
status: "no_remote".to_string(),
|
||||
message: "No remote configured".to_string(),
|
||||
status: NO_REMOTE_STATUS.to_string(),
|
||||
message: NO_REMOTE_MESSAGE.to_string(),
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.args(["pull", "--no-rebase"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
let output = git_output(vault, &["pull", "--no-rebase"])
|
||||
.map_err(|e| format!("Failed to run git pull: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let stdout = stdout_text(&output);
|
||||
let stderr = stderr_text(&output);
|
||||
|
||||
if output.status.success() {
|
||||
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
|
||||
@@ -134,18 +129,14 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
}
|
||||
|
||||
// Fetch latest remote refs (silent, best-effort)
|
||||
let _ = git_command()
|
||||
.args(["fetch", "--quiet"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = git_output(vault, &["fetch", "--quiet"]);
|
||||
|
||||
let branch = current_branch(vault)?;
|
||||
|
||||
let output = git_command()
|
||||
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git rev-list: {}", e))?;
|
||||
let output = git_output_result(
|
||||
vault,
|
||||
&["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
// No upstream set — report 0/0
|
||||
@@ -157,8 +148,8 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = stdout.trim().split('\t').collect();
|
||||
let stdout = stdout_text(&output);
|
||||
let parts: Vec<&str> = stdout.split('\t').collect();
|
||||
let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
|
||||
@@ -171,17 +162,14 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
}
|
||||
|
||||
fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
let output = git_command()
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
let output = git_output(vault, &["branch", "--show-current"])
|
||||
.map_err(|e| format!("Failed to get branch: {}", e))?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
Ok(stdout_text(&output))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "no_remote" | "error"
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
@@ -210,6 +198,10 @@ pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
);
|
||||
}
|
||||
|
||||
if is_no_remote_push_error(&lower) {
|
||||
return push_error("no_remote", "No remote configured");
|
||||
}
|
||||
|
||||
push_error(
|
||||
"error",
|
||||
format!("Push failed: {}", push_error_detail(stderr)),
|
||||
@@ -259,6 +251,18 @@ fn is_network_push_error(lower: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_no_remote_push_error(lower: &str) -> bool {
|
||||
contains_any(
|
||||
lower,
|
||||
&[
|
||||
"no configured push destination",
|
||||
"does not appear to be a git repository",
|
||||
"no such remote",
|
||||
"no upstream branch",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error_detail(stderr: &str) -> String {
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
@@ -283,14 +287,18 @@ fn push_error_detail(stderr: &str) -> String {
|
||||
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = git_command()
|
||||
.args(["push"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git push: {}", e))?;
|
||||
if !has_remote(vault_path)? {
|
||||
return Ok(GitPushResult {
|
||||
status: NO_REMOTE_STATUS.to_string(),
|
||||
message: NO_REMOTE_MESSAGE.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let output =
|
||||
git_output(vault, &["push"]).map_err(|e| format!("Failed to run git push: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr = stderr_text(&output);
|
||||
return Ok(classify_push_error(&stderr));
|
||||
}
|
||||
|
||||
@@ -303,10 +311,16 @@ pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_command;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use std::fs;
|
||||
|
||||
fn commit_default_note(vault_path: &Path) {
|
||||
fs::write(vault_path.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vault_path.to_str().unwrap(), "initial").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_false_for_local_repo() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -331,6 +345,27 @@ mod tests {
|
||||
assert!(has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_ignores_name_only_remote_without_url() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
git_command()
|
||||
.args(["config", "remote.origin.prune", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let remote_names = git_command()
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&remote_names.stdout).contains("origin"));
|
||||
assert!(!has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_no_remote_returns_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -430,6 +465,14 @@ hint: have locally."#;
|
||||
assert!(result.message.contains("network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_no_remote() {
|
||||
let stderr = "fatal: No configured push destination.";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "no_remote");
|
||||
assert!(result.message.contains("No remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown() {
|
||||
let stderr = "error: something unexpected happened\nhint: Try again later";
|
||||
@@ -463,12 +506,23 @@ hint: have locally."#;
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
commit_default_note(clone_a.path());
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_no_remote_returns_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
commit_default_note(vault);
|
||||
|
||||
let result = git_push(vp).unwrap();
|
||||
assert_eq!(result.status, "no_remote");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_rejected_returns_rejected() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
|
||||
59
src-tauri/src/git/remote_config.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::command::{git_output, stderr_or_failure, stdout_lines};
|
||||
|
||||
const DEFAULT_FETCH_REFSPEC: &str = "+refs/heads/*:refs/remotes/origin/*";
|
||||
const REMOTE_URL_CONFIG_PATTERN: &str = r"^remote\..*\.url$";
|
||||
const ORIGIN_URL_CONFIG_KEY: &str = "remote.origin.url";
|
||||
const ORIGIN_FETCH_CONFIG_KEY: &str = "remote.origin.fetch";
|
||||
|
||||
pub(super) fn has_configured_remote(vault: &Path) -> Result<bool, String> {
|
||||
Ok(!list_configured_remotes(vault)?.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn list_configured_remotes(vault: &Path) -> Result<Vec<String>, String> {
|
||||
let output = git_output(
|
||||
vault,
|
||||
&["config", "--get-regexp", REMOTE_URL_CONFIG_PATTERN],
|
||||
)
|
||||
.map_err(|e| format!("Failed to inspect git remotes: {e}"))?;
|
||||
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(stderr_or_failure("git config --get-regexp", &output));
|
||||
}
|
||||
|
||||
Ok(stdout_lines(&output)
|
||||
.into_iter()
|
||||
.filter_map(|line| remote_name_from_url_config(&line))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn remote_name_from_url_config(line: &str) -> Option<String> {
|
||||
let (key, value) = line.split_once(' ')?;
|
||||
if value.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
key.strip_prefix("remote.")
|
||||
.and_then(|name| name.strip_suffix(".url"))
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
pub(super) fn configure_origin_remote(vault: &Path, remote_url: &str) -> Result<(), String> {
|
||||
run_git_config(vault, ORIGIN_URL_CONFIG_KEY, remote_url)?;
|
||||
run_git_config(vault, ORIGIN_FETCH_CONFIG_KEY, DEFAULT_FETCH_REFSPEC)
|
||||
}
|
||||
|
||||
fn run_git_config(vault: &Path, key: &str, value: &str) -> Result<(), String> {
|
||||
let output = git_output(vault, &["config", "--local", "--replace-all", key, value])
|
||||
.map_err(|e| format!("Failed to run git config: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(stderr_or_failure("git config", &output))
|
||||
}
|
||||
@@ -23,6 +23,12 @@ struct DiffStats {
|
||||
binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct StatusEntry {
|
||||
status_code: String,
|
||||
relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileChangeStatus {
|
||||
Modified,
|
||||
@@ -54,31 +60,63 @@ impl FileChangeStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_status_path(raw_path: &str) -> String {
|
||||
raw_path
|
||||
.split(" -> ")
|
||||
.last()
|
||||
.unwrap_or(raw_path)
|
||||
.trim()
|
||||
.to_string()
|
||||
fn split_nul_fields(output: &[u8]) -> Vec<String> {
|
||||
output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(|field| String::from_utf8_lossy(field).into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn status_has_source_path(status_code: &str) -> bool {
|
||||
status_code.contains('R') || status_code.contains('C')
|
||||
}
|
||||
|
||||
fn parse_status_field(field: &str) -> Option<StatusEntry> {
|
||||
if field.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(StatusEntry {
|
||||
status_code: field[..2].to_string(),
|
||||
relative_path: field[3..].to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_status_output(output: &[u8]) -> Vec<StatusEntry> {
|
||||
let fields = split_nul_fields(output);
|
||||
let mut entries = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < fields.len() {
|
||||
let Some(entry) = parse_status_field(&fields[index]) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let has_source_path = status_has_source_path(&entry.status_code);
|
||||
entries.push(entry);
|
||||
index += if has_source_path { 2 } else { 1 };
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn parse_numstat_field(field: &str) -> Option<usize> {
|
||||
field.parse().ok()
|
||||
}
|
||||
|
||||
fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
fn parse_numstat_header(header: &str) -> Option<(Option<String>, DiffStats)> {
|
||||
let mut parts = header.splitn(3, '\t');
|
||||
let added = parts.next()?;
|
||||
let deleted = parts.next()?;
|
||||
let path = parts.next()?;
|
||||
|
||||
let added_lines = parse_numstat_field(parts[0]);
|
||||
let deleted_lines = parse_numstat_field(parts[1]);
|
||||
let binary = parts[0] == "-" || parts[1] == "-";
|
||||
let added_lines = parse_numstat_field(added);
|
||||
let deleted_lines = parse_numstat_field(deleted);
|
||||
let binary = added == "-" || deleted == "-";
|
||||
|
||||
Some((
|
||||
parts.last()?.trim().to_string(),
|
||||
(!path.is_empty()).then(|| path.to_string()),
|
||||
DiffStats {
|
||||
added_lines,
|
||||
deleted_lines,
|
||||
@@ -87,6 +125,35 @@ fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_numstat_output(output: &[u8]) -> HashMap<String, DiffStats> {
|
||||
let fields = split_nul_fields(output);
|
||||
let mut stats = HashMap::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < fields.len() {
|
||||
let Some((path, diff_stats)) = parse_numstat_header(&fields[index]) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
match path {
|
||||
Some(path) => {
|
||||
stats.insert(path, diff_stats);
|
||||
index += 1;
|
||||
}
|
||||
None if index + 2 < fields.len() => {
|
||||
stats.insert(fields[index + 2].clone(), diff_stats);
|
||||
index += 3;
|
||||
}
|
||||
None => {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
fn repo_has_head(vault: &Path) -> Result<bool, String> {
|
||||
let output = git_command()
|
||||
.args(["rev-parse", "--verify", "HEAD"])
|
||||
@@ -103,7 +170,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.args(["diff", "--numstat", "--find-renames", "HEAD", "--"])
|
||||
.args(["diff", "--numstat", "-z", "--find-renames", "HEAD", "--"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff --numstat: {e}"))?;
|
||||
@@ -113,11 +180,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
|
||||
return Err(format!("git diff --numstat failed: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.filter_map(parse_numstat_line)
|
||||
.collect::<HashMap<_, _>>())
|
||||
Ok(parse_numstat_output(&output.stdout))
|
||||
}
|
||||
|
||||
fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats {
|
||||
@@ -229,7 +292,7 @@ pub fn get_modified_files_with_stats(
|
||||
|
||||
fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result<Vec<ModifiedFile>, String> {
|
||||
let output = git_command()
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
@@ -244,27 +307,22 @@ fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result<Vec<Modi
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let files = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|line| {
|
||||
if line.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let status_code = &line[..2];
|
||||
let relative_path = parse_status_path(&line[3..]);
|
||||
|
||||
let files = parse_status_output(&output.stdout)
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
// Only include markdown files
|
||||
if !relative_path.ends_with(".md") {
|
||||
if !entry.relative_path.ends_with(".md") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = FileChangeStatus::from_code(status_code);
|
||||
let full_path = vault.join(&relative_path).to_string_lossy().to_string();
|
||||
let status = FileChangeStatus::from_code(&entry.status_code);
|
||||
let full_path = vault
|
||||
.join(&entry.relative_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let stats = resolve_diff_stats(
|
||||
vault,
|
||||
Path::new(&relative_path),
|
||||
Path::new(&entry.relative_path),
|
||||
status,
|
||||
&diff_stats,
|
||||
include_stats,
|
||||
@@ -272,7 +330,7 @@ fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result<Vec<Modi
|
||||
|
||||
Some(ModifiedFile {
|
||||
path: full_path,
|
||||
relative_path,
|
||||
relative_path: entry.relative_path,
|
||||
status: status.label().to_string(),
|
||||
added_lines: stats.added_lines,
|
||||
deleted_lines: stats.deleted_lines,
|
||||
@@ -333,6 +391,32 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn expect_modified_file(vp: &str, relative_path: &str, status: &str) -> ModifiedFile {
|
||||
let modified = get_modified_files_with_stats(vp).unwrap();
|
||||
let file = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == relative_path)
|
||||
.unwrap_or_else(|| panic!("{relative_path} should be reported as {status}"));
|
||||
|
||||
assert_eq!(file.status, status);
|
||||
assert!(file.path.ends_with(relative_path));
|
||||
file.clone()
|
||||
}
|
||||
|
||||
fn expect_changed_file_after(
|
||||
relative_path: &str,
|
||||
status: &str,
|
||||
change: impl FnOnce(&Path, &str),
|
||||
) -> ModifiedFile {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
change(vault, vp);
|
||||
|
||||
expect_modified_file(vp, relative_path, status)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_with_stats() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -443,26 +527,54 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_chinese_markdown_path() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
let relative_path = "中文笔记.md";
|
||||
|
||||
force_quoted_git_paths(vault);
|
||||
write_and_commit_markdown(vault, vp, relative_path, "# 初始\n");
|
||||
fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap();
|
||||
|
||||
let modified = get_modified_files_with_stats(vp).unwrap();
|
||||
let file = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == relative_path)
|
||||
.expect("Chinese markdown path should be reported as modified");
|
||||
|
||||
assert_eq!(file.status, "modified");
|
||||
assert!(file.path.ends_with(relative_path));
|
||||
let file = expect_changed_file_after(relative_path, "modified", |vault, vp| {
|
||||
force_quoted_git_paths(vault);
|
||||
write_and_commit_markdown(vault, vp, relative_path, "# 初始\n");
|
||||
fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap();
|
||||
});
|
||||
assert_eq!(file.added_lines, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_untracked_markdown_path_with_spaces() {
|
||||
let relative_path = "test note.md";
|
||||
|
||||
let file = expect_changed_file_after(relative_path, "untracked", |vault, vp| {
|
||||
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
|
||||
fs::write(vault.join(relative_path), "# Test\n").unwrap();
|
||||
});
|
||||
assert_eq!(file.added_lines, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_modified_markdown_path_with_spaces() {
|
||||
let relative_path = "test note.md";
|
||||
|
||||
let file = expect_changed_file_after(relative_path, "modified", |vault, vp| {
|
||||
write_and_commit_markdown(vault, vp, relative_path, "# Test\n");
|
||||
fs::write(vault.join(relative_path), "# Test\n\nUpdated\n").unwrap();
|
||||
});
|
||||
assert_eq!(file.added_lines, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_renamed_markdown_path_with_spaces() {
|
||||
let relative_path = "test note.md";
|
||||
|
||||
let file = expect_changed_file_after(relative_path, "renamed", |vault, vp| {
|
||||
write_and_commit_markdown(vault, vp, "alpha.md", "# Alpha\n");
|
||||
git_command()
|
||||
.args(["mv", "alpha.md", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
});
|
||||
assert_eq!(file.added_lines, Some(0));
|
||||
assert_eq!(file.deleted_lines, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_flow_modified_files_then_commit_clears() {
|
||||
let dir = setup_git_repo();
|
||||
|
||||
320
src-tauri/src/kiro_cli.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use regex::Regex;
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{ChildStderr, ChildStdin, ChildStdout, Stdio};
|
||||
|
||||
struct KiroMcpConfig<'a> {
|
||||
vault_path: &'a str,
|
||||
vault_paths: &'a [String],
|
||||
mcp_server_path: &'a str,
|
||||
}
|
||||
|
||||
struct KiroError<'a> {
|
||||
stderr_output: &'a str,
|
||||
status: String,
|
||||
}
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::kiro_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::kiro_discovery::find_binary()?;
|
||||
ensure_mcp_config(&request)?;
|
||||
let prompt =
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref());
|
||||
|
||||
let mut child = spawn_kiro_process(&binary, Path::new(&request.vault_path))?;
|
||||
let prompt_handle = write_prompt_async(
|
||||
child.stdin.take().ok_or("No stdin handle")?,
|
||||
prompt.into_bytes(),
|
||||
);
|
||||
let stderr_handle = read_stderr_async(child.stderr.take().ok_or("No stderr handle")?);
|
||||
|
||||
let session_id = generate_session_id();
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
|
||||
stream_stdout(child.stdout.take().ok_or("No stdout handle")?, &mut emit);
|
||||
|
||||
let mut stderr_output = stderr_handle.join().unwrap_or_default();
|
||||
if let Some(error) = prompt_write_error(prompt_handle) {
|
||||
append_stderr_line(&mut stderr_output, error);
|
||||
}
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_kiro_error(KiroError {
|
||||
stderr_output: &stderr_output,
|
||||
status: status.to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
fn spawn_kiro_process(binary: &Path, vault_path: &Path) -> Result<std::process::Child, String> {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
||||
command
|
||||
.arg("chat")
|
||||
.arg("--no-interactive")
|
||||
.arg("--trust-all-tools")
|
||||
.current_dir(vault_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn kiro-cli: {e}"))
|
||||
}
|
||||
|
||||
fn write_prompt_async(
|
||||
mut stdin: ChildStdin,
|
||||
prompt: Vec<u8>,
|
||||
) -> std::thread::JoinHandle<Result<(), String>> {
|
||||
std::thread::spawn(move || {
|
||||
stdin
|
||||
.write_all(&prompt)
|
||||
.map_err(|e| format!("Failed to write kiro-cli stdin: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
format!("kiro-{}-{}", std::process::id(), ts)
|
||||
}
|
||||
|
||||
fn stream_stdout<F>(stdout: ChildStdout, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(l) if !l.is_empty() => {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: format!("{}\n", strip_ansi_codes(&l)),
|
||||
});
|
||||
}
|
||||
Ok(_) => {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: "\n".to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stderr_async(mut stderr: ChildStderr) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut output = String::new();
|
||||
let _ = stderr.read_to_string(&mut output);
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_write_error(handle: std::thread::JoinHandle<Result<(), String>>) -> Option<String> {
|
||||
match handle.join() {
|
||||
Ok(Ok(())) => None,
|
||||
Ok(Err(error)) => Some(error),
|
||||
Err(_) => Some("Failed to write kiro-cli stdin: writer thread panicked".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_stderr_line(stderr_output: &mut String, line: impl AsRef<str>) {
|
||||
if !stderr_output.is_empty() {
|
||||
stderr_output.push('\n');
|
||||
}
|
||||
stderr_output.push_str(line.as_ref());
|
||||
}
|
||||
|
||||
fn ensure_mcp_config(request: &AgentStreamRequest) -> Result<(), String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
write_mcp_json(KiroMcpConfig {
|
||||
vault_path: &request.vault_path,
|
||||
vault_paths: &request.vault_paths,
|
||||
mcp_server_path: &mcp_server_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_mcp_json(config: KiroMcpConfig<'_>) -> Result<(), String> {
|
||||
let config_dir = Path::new(config.vault_path).join(".kiro").join("settings");
|
||||
std::fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create .kiro/settings: {e}"))?;
|
||||
|
||||
let config_path = config_dir.join("mcp.json");
|
||||
|
||||
let mut json_config: serde_json::Value = std::fs::read_to_string(&config_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let servers = json_config
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid mcp.json: not an object")?
|
||||
.entry("mcpServers")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
let active_vault_paths =
|
||||
crate::cli_agent_runtime::active_vault_paths_json(config.vault_path, config.vault_paths);
|
||||
servers["tolaria"] = serde_json::json!({
|
||||
"command": "node",
|
||||
"args": [config.mcp_server_path],
|
||||
"env": {
|
||||
"VAULT_PATH": config.vault_path,
|
||||
"VAULT_PATHS": active_vault_paths,
|
||||
"WS_UI_PORT": "9711"
|
||||
},
|
||||
"disabled": false
|
||||
});
|
||||
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
serde_json::to_string_pretty(&json_config)
|
||||
.map_err(|e| format!("JSON serialize error: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write mcp.json: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strip_ansi_codes(input: &str) -> String {
|
||||
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let re = RE.get_or_init(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
|
||||
re.replace_all(input, "").to_string()
|
||||
}
|
||||
|
||||
fn format_kiro_error(error: KiroError<'_>) -> String {
|
||||
if is_auth_error(error.stderr_output) {
|
||||
return "Kiro CLI is not authenticated. Run `kiro-cli login` in your terminal to sign in."
|
||||
.into();
|
||||
}
|
||||
if error.stderr_output.trim().is_empty() {
|
||||
format!("kiro-cli exited with status {}", error.status)
|
||||
} else {
|
||||
error
|
||||
.stderr_output
|
||||
.lines()
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_auth_error(stderr_output: &str) -> bool {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
["auth", "login", "token"]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_ansi_codes_removes_terminal_colors() {
|
||||
assert_eq!(
|
||||
strip_ansi_codes("\x1b[38;5;141m> \x1b[0mHello! \x1b[2K"),
|
||||
"> Hello! "
|
||||
);
|
||||
assert_eq!(strip_ansi_codes("plain text"), "plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_kiro_error_detects_auth_errors() {
|
||||
let result = format_kiro_error(KiroError {
|
||||
stderr_output: "Error: auth token expired",
|
||||
status: "1".into(),
|
||||
});
|
||||
assert!(result.contains("kiro-cli login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_kiro_error_returns_status_for_empty_stderr() {
|
||||
let result = format_kiro_error(KiroError {
|
||||
stderr_output: "",
|
||||
status: "1".into(),
|
||||
});
|
||||
assert!(result.contains("status 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_mcp_json_creates_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
write_mcp_json(KiroMcpConfig {
|
||||
vault_path,
|
||||
vault_paths: &["/other/vault".into(), vault_path.into()],
|
||||
mcp_server_path: "/opt/mcp/index.js",
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let config_path = dir.path().join(".kiro/settings/mcp.json");
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
|
||||
assert_eq!(content["mcpServers"]["tolaria"]["command"], "node");
|
||||
assert_eq!(
|
||||
content["mcpServers"]["tolaria"]["args"][0],
|
||||
"/opt/mcp/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
content["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
|
||||
vault_path
|
||||
);
|
||||
assert_eq!(
|
||||
content["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"],
|
||||
serde_json::json!(serde_json::to_string(&vec![vault_path, "/other/vault"]).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
content["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"],
|
||||
"9711"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_mcp_json_merges_preserving_existing_servers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let config_dir = dir.path().join(".kiro/settings");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("mcp.json"),
|
||||
r#"{"mcpServers":{"other":{"command":"python","args":["server.py"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
write_mcp_json(KiroMcpConfig {
|
||||
vault_path,
|
||||
vault_paths: &[],
|
||||
mcp_server_path: "/new/index.js",
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let content: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(dir.path().join(".kiro/settings/mcp.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(content["mcpServers"]["tolaria"]["args"][0], "/new/index.js");
|
||||
assert_eq!(content["mcpServers"]["other"]["command"], "python");
|
||||
}
|
||||
}
|
||||
59
src-tauri/src/kiro_discovery.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use crate::ai_agents::AiAgentAvailability;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
crate::cli_agent_runtime::check_cli_availability(find_binary)
|
||||
}
|
||||
|
||||
pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
crate::cli_agent_runtime::find_cli_binary(
|
||||
"kiro-cli",
|
||||
kiro_binary_candidates(),
|
||||
"Kiro CLI",
|
||||
"https://kiro.dev/docs/cli",
|
||||
)
|
||||
}
|
||||
|
||||
fn kiro_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| kiro_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn kiro_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/kiro-cli"),
|
||||
home.join(".kiro/bin/kiro-cli"),
|
||||
home.join(".local/share/mise/shims/kiro-cli"),
|
||||
home.join(".asdf/shims/kiro-cli"),
|
||||
home.join(".npm-global/bin/kiro-cli"),
|
||||
home.join(".npm/bin/kiro-cli"),
|
||||
home.join(".bun/bin/kiro-cli"),
|
||||
PathBuf::from("/usr/local/bin/kiro-cli"),
|
||||
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_supported_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = kiro_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/kiro-cli"),
|
||||
home.join(".kiro/bin/kiro-cli"),
|
||||
home.join(".npm-global/bin/kiro-cli"),
|
||||
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
|
||||
];
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ai_agents;
|
||||
pub mod ai_models;
|
||||
mod app_icon;
|
||||
pub mod app_updater;
|
||||
pub mod claude_cli;
|
||||
mod claude_invocation;
|
||||
@@ -11,6 +12,8 @@ pub mod gemini_cli;
|
||||
mod gemini_config;
|
||||
mod gemini_discovery;
|
||||
pub mod git;
|
||||
pub mod kiro_cli;
|
||||
mod kiro_discovery;
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
mod linux_appimage;
|
||||
pub mod mcp;
|
||||
@@ -275,16 +278,52 @@ fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn focus_main_window(app_handle: &tauri::AppHandle) {
|
||||
use tauri::Manager;
|
||||
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn with_desktop_entry_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
builder
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
focus_main_window(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn setup_deep_link_runtime_registration(
|
||||
_app: &mut tauri::App,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
_app.deep_link().register_all()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
setup_macos_webview_shortcut_prevention(app)?;
|
||||
setup_deep_link_runtime_registration(app)?;
|
||||
app.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
app.handle().plugin(tauri_plugin_opener::init())?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
menu::setup_menu(app)?;
|
||||
setup_linux_window_chrome(app)?;
|
||||
if should_use_native_desktop_menu(std::env::consts::OS) {
|
||||
menu::setup_menu(app)?;
|
||||
}
|
||||
setup_custom_window_chrome(app)?;
|
||||
window_state::restore_main_window_state(app);
|
||||
show_debug_main_window(app);
|
||||
Ok(())
|
||||
@@ -305,8 +344,12 @@ fn show_debug_main_window(app: &mut tauri::App) {
|
||||
#[cfg(not(debug_assertions))]
|
||||
fn show_debug_main_window(_app: &mut tauri::App) {}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn should_use_native_desktop_menu(target_os: &str) -> bool {
|
||||
target_os == "macos"
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, any(target_os = "linux", target_os = "windows")))]
|
||||
fn setup_custom_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri::Manager;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -315,8 +358,8 @@ fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::er
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(not(all(desktop, any(target_os = "linux", target_os = "windows"))))]
|
||||
fn setup_custom_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -492,12 +535,15 @@ macro_rules! app_invoke_handler {
|
||||
commands::delete_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::get_ai_workspace_sessions,
|
||||
commands::check_for_app_update,
|
||||
commands::update_menu_state,
|
||||
commands::update_app_icon,
|
||||
commands::trigger_menu_command,
|
||||
commands::update_current_window_min_size,
|
||||
commands::perform_current_window_titlebar_double_click,
|
||||
commands::save_settings,
|
||||
commands::save_ai_workspace_sessions,
|
||||
commands::download_and_install_app_update,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
@@ -517,6 +563,8 @@ macro_rules! app_invoke_handler {
|
||||
commands::get_process_memory_snapshot,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::should_use_external_media_preview,
|
||||
commands::print_current_webview,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd,
|
||||
@@ -550,6 +598,9 @@ pub fn run() {
|
||||
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = with_desktop_entry_plugins(builder);
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder
|
||||
.manage(WsBridgeChild(Mutex::new(None)))
|
||||
@@ -569,6 +620,7 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_use_native_desktop_menu;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
@@ -723,4 +775,11 @@ mod tests {
|
||||
missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_desktop_menu_is_macos_only() {
|
||||
assert!(should_use_native_desktop_menu("macos"));
|
||||
assert!(!should_use_native_desktop_menu("windows"));
|
||||
assert!(!should_use_native_desktop_menu("linux"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,44 +24,108 @@ pub enum McpStatus {
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
/// Find the `node` binary path at runtime.
|
||||
pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
let mut last_error = None;
|
||||
for path in node_binary_candidates() {
|
||||
match verify_node_version(&path) {
|
||||
Ok(()) => return Ok(path),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
|
||||
/// A resolved runtime that can execute the MCP server scripts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct McpRuntime {
|
||||
pub(crate) kind: McpRuntimeKind,
|
||||
pub(crate) binary: PathBuf,
|
||||
}
|
||||
|
||||
fn node_binary_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = find_node_on_path();
|
||||
candidates.extend(find_node_in_user_shell());
|
||||
candidates.extend(fallback_node_paths());
|
||||
/// Which JS runtime was selected for the MCP server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum McpRuntimeKind {
|
||||
Node,
|
||||
Bun,
|
||||
}
|
||||
|
||||
impl McpRuntimeKind {
|
||||
fn binary_name(self) -> &'static str {
|
||||
match self {
|
||||
McpRuntimeKind::Node => node_binary_name(),
|
||||
McpRuntimeKind::Bun => bun_binary_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find any supported MCP runtime, preferring Node over Bun.
|
||||
pub(crate) fn find_mcp_runtime() -> Result<McpRuntime, String> {
|
||||
let mut last_error = None;
|
||||
for kind in [McpRuntimeKind::Node, McpRuntimeKind::Bun] {
|
||||
if let Some(binary) = try_runtime(kind, &mut last_error) {
|
||||
return Ok(McpRuntime { kind, binary });
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
"No supported MCP runtime found. Install Node.js 18+ or Bun 1+ and ensure it's on PATH."
|
||||
.into()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Find the `node` binary specifically. Used by Codex/CLI agent shims that
|
||||
/// require Node and cannot fall back to Bun.
|
||||
pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
let mut last_error = None;
|
||||
if let Some(binary) = try_runtime(McpRuntimeKind::Node, &mut last_error) {
|
||||
return Ok(binary);
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{} not found in PATH or common install locations",
|
||||
McpRuntimeKind::Node.binary_name()
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn try_runtime(kind: McpRuntimeKind, last_error: &mut Option<String>) -> Option<PathBuf> {
|
||||
for path in runtime_binary_candidates(kind) {
|
||||
match verify_runtime_version(kind, &path) {
|
||||
Ok(()) => return Some(path),
|
||||
Err(error) => *last_error = Some(error),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn runtime_binary_candidates(kind: McpRuntimeKind) -> Vec<PathBuf> {
|
||||
let command = kind.binary_name();
|
||||
let mut candidates = find_on_path(command);
|
||||
candidates.extend(find_in_user_shell(command));
|
||||
candidates.extend(fallback_paths_for(kind));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_node_on_path() -> Vec<PathBuf> {
|
||||
node_lookup_command()
|
||||
fn fallback_paths_for(kind: McpRuntimeKind) -> Vec<PathBuf> {
|
||||
match kind {
|
||||
McpRuntimeKind::Node => fallback_node_paths(),
|
||||
McpRuntimeKind::Bun => fallback_bun_paths(),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_runtime_version(kind: McpRuntimeKind, path: &Path) -> Result<(), String> {
|
||||
match kind {
|
||||
McpRuntimeKind::Node => verify_node_version(path),
|
||||
McpRuntimeKind::Bun => verify_bun_version(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_on_path(command: &str) -> Vec<PathBuf> {
|
||||
lookup_command(command)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| node_lookup_paths(&output.stdout))
|
||||
.map(|output| lookup_paths(&output.stdout))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn find_node_in_user_shell() -> Vec<PathBuf> {
|
||||
fn find_in_user_shell(command: &str) -> Vec<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.filter_map(|shell| command_path_from_shell(&shell, "node"))
|
||||
.filter_map(|shell| command_path_from_shell(&shell, command))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn node_lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
|
||||
fn lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
|
||||
String::from_utf8_lossy(stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
@@ -148,14 +212,14 @@ fn node_major_version(version: &str) -> Option<u32> {
|
||||
.and_then(|major| major.parse().ok())
|
||||
}
|
||||
|
||||
fn node_lookup_command() -> Command {
|
||||
fn lookup_command(command: &str) -> Command {
|
||||
#[cfg(windows)]
|
||||
let mut command = subprocess::command("where.exe");
|
||||
let mut cmd = subprocess::command("where.exe");
|
||||
#[cfg(not(windows))]
|
||||
let mut command = subprocess::command("which");
|
||||
let mut cmd = subprocess::command("which");
|
||||
|
||||
command.arg("node");
|
||||
command
|
||||
cmd.arg(command);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn fallback_node_paths() -> Vec<PathBuf> {
|
||||
@@ -164,6 +228,9 @@ fn fallback_node_paths() -> Vec<PathBuf> {
|
||||
PathBuf::from("/usr/local/bin/node"),
|
||||
];
|
||||
|
||||
#[cfg(not(windows))]
|
||||
candidates.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin/node"));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(program_files) = std::env::var_os("ProgramFiles") {
|
||||
@@ -203,6 +270,7 @@ fn node_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".mise").join("shims").join(node_binary_name()),
|
||||
home.join(".asdf").join("shims").join(node_binary_name()),
|
||||
home.join(".volta").join("bin").join(node_binary_name()),
|
||||
home.join(".linuxbrew").join("bin").join(node_binary_name()),
|
||||
];
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
@@ -230,6 +298,81 @@ fn node_binary_name() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_bun_paths() -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/opt/homebrew/bin/bun"),
|
||||
PathBuf::from("/usr/local/bin/bun"),
|
||||
];
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
||||
candidates.push(
|
||||
PathBuf::from(profile)
|
||||
.join(".bun")
|
||||
.join("bin")
|
||||
.join("bun.exe"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.extend(bun_binary_candidates_for_home(&home));
|
||||
}
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|path| path.is_file())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bun_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".bun").join("bin").join(bun_binary_name()),
|
||||
home.join(".local/share/mise/shims").join(bun_binary_name()),
|
||||
home.join(".mise").join("shims").join(bun_binary_name()),
|
||||
home.join(".asdf").join("shims").join(bun_binary_name()),
|
||||
home.join(".proto").join("bin").join(bun_binary_name()),
|
||||
]
|
||||
}
|
||||
|
||||
fn bun_binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"bun.exe"
|
||||
} else {
|
||||
"bun"
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_bun_version(bun: &Path) -> Result<(), String> {
|
||||
let output = subprocess::command(bun)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {} --version: {e}", bun.display()))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"{} --version failed; install Bun 1+ and make it available on PATH",
|
||||
bun.display()
|
||||
));
|
||||
}
|
||||
|
||||
let raw_version = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(major) = node_major_version(&raw_version) else {
|
||||
return Err(format!(
|
||||
"Cannot parse Bun version from '{}'",
|
||||
raw_version.trim()
|
||||
));
|
||||
};
|
||||
if major < 1 {
|
||||
return Err(format!(
|
||||
"Bun 1+ is required for Tolaria MCP tools; found {}",
|
||||
raw_version.trim()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the path to `mcp-server/`.
|
||||
///
|
||||
/// In dev mode, prefers `CARGO_MANIFEST_DIR` and falls back to runtime checkout ancestors.
|
||||
@@ -272,21 +415,14 @@ fn build_time_dev_mcp_server_dir() -> PathBuf {
|
||||
|
||||
fn mcp_server_dir_candidates(dev_path: &Path, resource_roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let current_dir = std::env::current_dir().ok();
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
|
||||
mcp_server_dir_candidates_for(
|
||||
dev_path,
|
||||
resource_roots,
|
||||
current_dir.as_deref(),
|
||||
current_exe.as_deref(),
|
||||
)
|
||||
mcp_server_dir_candidates_for(dev_path, resource_roots, current_dir.as_deref())
|
||||
}
|
||||
|
||||
fn mcp_server_dir_candidates_for(
|
||||
dev_path: &Path,
|
||||
resource_roots: &[PathBuf],
|
||||
current_dir: Option<&Path>,
|
||||
current_exe: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
push_unique_path(&mut candidates, dev_path.to_path_buf());
|
||||
@@ -295,13 +431,7 @@ fn mcp_server_dir_candidates_for(
|
||||
push_resource_root_candidates(&mut candidates, root);
|
||||
}
|
||||
|
||||
if let Some(current_exe) = current_exe {
|
||||
for root in executable_resource_roots(current_exe) {
|
||||
push_resource_root_candidates(&mut candidates, &root);
|
||||
}
|
||||
}
|
||||
|
||||
for root in runtime_development_roots(current_dir, current_exe) {
|
||||
for root in runtime_development_roots(current_dir) {
|
||||
push_development_root_candidates(&mut candidates, &root);
|
||||
}
|
||||
|
||||
@@ -319,41 +449,18 @@ fn push_resource_root_candidates(candidates: &mut Vec<PathBuf>, root: &Path) {
|
||||
fn push_development_root_candidates(candidates: &mut Vec<PathBuf>, root: &Path) {
|
||||
push_unique_path(candidates, root.join("mcp-server"));
|
||||
push_unique_path(candidates, root.join("resources").join("mcp-server"));
|
||||
push_unique_path(
|
||||
candidates,
|
||||
root.join("src-tauri").join("resources").join("mcp-server"),
|
||||
);
|
||||
}
|
||||
|
||||
fn runtime_development_roots(
|
||||
current_dir: Option<&Path>,
|
||||
current_exe: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
fn runtime_development_roots(current_dir: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Some(current_dir) = current_dir {
|
||||
push_ancestor_paths(&mut roots, current_dir);
|
||||
}
|
||||
if let Some(exe_dir) = current_exe.and_then(Path::parent) {
|
||||
push_ancestor_paths(&mut roots, exe_dir);
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn executable_resource_roots(current_exe: &Path) -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
let Some(exe_dir) = current_exe.parent() else {
|
||||
return roots;
|
||||
};
|
||||
|
||||
if exe_dir.file_name().and_then(|name| name.to_str()) == Some("MacOS") {
|
||||
if let Some(contents_dir) = exe_dir.parent() {
|
||||
push_unique_path(&mut roots, contents_dir.join("Resources"));
|
||||
}
|
||||
}
|
||||
|
||||
push_unique_path(&mut roots, exe_dir.to_path_buf());
|
||||
if let Some(parent) = exe_dir.parent() {
|
||||
push_unique_path(&mut roots, parent.join("Resources"));
|
||||
push_unique_path(&mut roots, parent.to_path_buf());
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
@@ -407,13 +514,13 @@ pub fn spawn_ws_bridge_with_paths(
|
||||
vault_path: impl AsRef<Path>,
|
||||
vault_paths: &[PathBuf],
|
||||
) -> Result<Child, String> {
|
||||
let node = find_node()?;
|
||||
let runtime = find_mcp_runtime()?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let script = server_dir.join("ws-bridge.js");
|
||||
let vault_path = vault_path.as_ref();
|
||||
let active_vault_paths = active_vault_paths_json(vault_path, vault_paths);
|
||||
|
||||
let mut command = subprocess::command(node);
|
||||
let mut command = subprocess::command(&runtime.binary);
|
||||
let child = command
|
||||
.arg(&script)
|
||||
.env("VAULT_PATH", vault_path)
|
||||
@@ -427,8 +534,9 @@ pub fn spawn_ws_bridge_with_paths(
|
||||
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"ws-bridge spawned (pid: {}, vault: {})",
|
||||
"ws-bridge spawned (pid: {}, runtime: {:?}, vault: {})",
|
||||
child.id(),
|
||||
runtime.kind,
|
||||
vault_path.display()
|
||||
);
|
||||
Ok(child)
|
||||
@@ -498,10 +606,10 @@ fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
|
||||
}
|
||||
|
||||
/// Build the durable external MCP server entry JSON for an index.js path.
|
||||
fn build_mcp_entry(node_command: &str, index_js: &str) -> serde_json::Value {
|
||||
fn build_mcp_entry(runtime_command: &str, index_js: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": node_command,
|
||||
"command": runtime_command,
|
||||
"args": [index_js],
|
||||
"env": {
|
||||
"WS_UI_PORT": "9711"
|
||||
@@ -521,13 +629,15 @@ fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String>
|
||||
/// Build the exact MCP config JSON users can copy into compatible tools.
|
||||
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
|
||||
let _ = vault_path;
|
||||
let node = find_node().map_err(|e| {
|
||||
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
|
||||
let runtime = find_mcp_runtime().map_err(|e| {
|
||||
format!(
|
||||
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can build MCP config: {e}"
|
||||
)
|
||||
})?;
|
||||
let server_dir = mcp_server_dir_for_registration()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
let node_command = node.to_string_lossy().into_owned();
|
||||
let entry = build_mcp_entry(&node_command, &index_js);
|
||||
let runtime_command = runtime.binary.to_string_lossy().into_owned();
|
||||
let entry = build_mcp_entry(&runtime_command, &index_js);
|
||||
|
||||
build_mcp_config_snippet(&entry)
|
||||
}
|
||||
@@ -549,15 +659,17 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
|
||||
/// Register Tolaria as an MCP server in external AI tool config files.
|
||||
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
let _ = vault_path;
|
||||
let node = find_node().map_err(|e| {
|
||||
format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {e}")
|
||||
let runtime = find_mcp_runtime().map_err(|e| {
|
||||
format!(
|
||||
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can register MCP tools: {e}"
|
||||
)
|
||||
})?;
|
||||
let server_dir = mcp_server_dir_for_registration()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
let node_command = node.to_string_lossy().into_owned();
|
||||
let runtime_command = runtime.binary.to_string_lossy().into_owned();
|
||||
|
||||
let entry = build_mcp_entry(&node_command, &index_js);
|
||||
let opencode_entry = opencode::build_entry(&node_command, &index_js);
|
||||
let entry = build_mcp_entry(&runtime_command, &index_js);
|
||||
let opencode_entry = opencode::build_entry(&runtime_command, &index_js);
|
||||
if let Some(config_path) = opencode::config_path() {
|
||||
if let Err(e) = opencode::upsert_config(&config_path, &opencode_entry) {
|
||||
log::warn!("Failed to update {}: {}", config_path.display(), e);
|
||||
@@ -793,10 +905,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_lookup_paths_keep_non_empty_lines_in_order() {
|
||||
fn lookup_paths_keep_non_empty_lines_in_order() {
|
||||
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
|
||||
assert_eq!(
|
||||
node_lookup_paths(stdout),
|
||||
lookup_paths(stdout),
|
||||
vec![
|
||||
PathBuf::from("C:\\Program Files\\nodejs\\node.exe"),
|
||||
PathBuf::from("C:\\Other\\node.exe"),
|
||||
@@ -855,6 +967,7 @@ mod tests {
|
||||
home.join(".local/share/mise/shims/node"),
|
||||
home.join(".asdf/shims/node"),
|
||||
home.join(".volta/bin/node"),
|
||||
home.join(".linuxbrew/bin/node"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
@@ -935,13 +1048,7 @@ mod tests {
|
||||
fn mcp_server_dir_candidates_include_runtime_dev_roots_when_build_path_is_stale() {
|
||||
let stale_dev_path = Path::new("/Users/runner/work/tolaria/tolaria/mcp-server");
|
||||
let current_dir = Path::new("/Users/luca/Workspace/tolaria");
|
||||
let current_exe = Path::new("/Users/luca/Workspace/tolaria/src-tauri/target/debug/tolaria");
|
||||
let candidates = mcp_server_dir_candidates_for(
|
||||
stale_dev_path,
|
||||
&[],
|
||||
Some(current_dir),
|
||||
Some(current_exe),
|
||||
);
|
||||
let candidates = mcp_server_dir_candidates_for(stale_dev_path, &[], Some(current_dir));
|
||||
|
||||
assert!(candidates.contains(&PathBuf::from("/Users/luca/Workspace/tolaria/mcp-server")));
|
||||
assert!(candidates.contains(&PathBuf::from(
|
||||
@@ -952,8 +1059,10 @@ mod tests {
|
||||
#[test]
|
||||
fn mcp_server_dir_candidates_include_macos_bundle_resources() {
|
||||
let dev_path = Path::new("/repo/mcp-server");
|
||||
let current_exe = Path::new("/Applications/Tolaria.app/Contents/MacOS/Tolaria");
|
||||
let candidates = mcp_server_dir_candidates_for(dev_path, &[], None, Some(current_exe));
|
||||
let resource_roots = vec![PathBuf::from(
|
||||
"/Applications/Tolaria.app/Contents/Resources",
|
||||
)];
|
||||
let candidates = mcp_server_dir_candidates_for(dev_path, &resource_roots, None);
|
||||
|
||||
assert!(candidates.contains(&PathBuf::from(
|
||||
"/Applications/Tolaria.app/Contents/Resources/mcp-server"
|
||||
@@ -1143,6 +1252,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_mcp_runtime_returns_valid_runtime() {
|
||||
let runtime = find_mcp_runtime().unwrap();
|
||||
assert!(
|
||||
runtime.binary.exists(),
|
||||
"runtime binary should exist at {:?}",
|
||||
runtime.binary
|
||||
);
|
||||
let expected = match runtime.kind {
|
||||
McpRuntimeKind::Node => "node",
|
||||
McpRuntimeKind::Bun => "bun",
|
||||
};
|
||||
assert!(
|
||||
runtime.binary.to_string_lossy().contains(expected),
|
||||
"path should contain '{expected}': {:?}",
|
||||
runtime.binary
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bun_binary_candidates_include_shell_managed_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = bun_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".bun/bin/bun"),
|
||||
home.join(".local/share/mise/shims/bun"),
|
||||
home.join(".mise/shims/bun"),
|
||||
home.join(".asdf/shims/bun"),
|
||||
home.join(".proto/bin/bun"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bun_version_accepts_real_bun_binary() {
|
||||
let Ok(bun) = find_bun_for_test() else {
|
||||
// Bun is optional on dev machines; skip when absent.
|
||||
return;
|
||||
};
|
||||
verify_bun_version(&bun).expect("installed bun should satisfy version requirement");
|
||||
}
|
||||
|
||||
fn find_bun_for_test() -> Result<PathBuf, String> {
|
||||
let mut last_error = None;
|
||||
if let Some(bin) = try_runtime(McpRuntimeKind::Bun, &mut last_error) {
|
||||
return Ok(bin);
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| "bun not present in test environment".into()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_resolves_in_dev() {
|
||||
let dir = mcp_server_dir().unwrap();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
|
||||
@@ -6,34 +7,35 @@ pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
|
||||
runtime_resource_roots_for_env(
|
||||
runtime_resource_roots_for_env_and_exe(
|
||||
non_empty_env_path("RESOURCEPATH"),
|
||||
non_empty_env_path("APPDIR"),
|
||||
current_exe_dir(),
|
||||
local_app_data,
|
||||
current_exe.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_resource_roots_for_env(
|
||||
fn runtime_resource_roots_for_env_and_exe(
|
||||
resource_path: Option<PathBuf>,
|
||||
appdir: Option<PathBuf>,
|
||||
exe_dir: Option<PathBuf>,
|
||||
local_app_data: Option<PathBuf>,
|
||||
current_exe: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Some(resource_path) = resource_path {
|
||||
push_resource_root(&mut roots, resource_path);
|
||||
}
|
||||
if let Some(current_exe) = current_exe {
|
||||
push_current_exe_resource_roots(&mut roots, current_exe);
|
||||
}
|
||||
if let Some(appdir) = appdir {
|
||||
push_resource_root(&mut roots, appdir.join("usr"));
|
||||
push_resource_root(&mut roots, appdir.join("usr/lib/tolaria"));
|
||||
push_resource_root(&mut roots, appdir.join("usr/lib/Tolaria"));
|
||||
}
|
||||
if let Some(exe_dir) = exe_dir {
|
||||
push_resource_root(&mut roots, exe_dir);
|
||||
}
|
||||
if let Some(local_app_data) = local_app_data {
|
||||
push_resource_root(&mut roots, local_app_data.join("Tolaria"));
|
||||
push_resource_root(&mut roots, local_app_data.join("tolaria"));
|
||||
@@ -42,10 +44,35 @@ fn runtime_resource_roots_for_env(
|
||||
roots
|
||||
}
|
||||
|
||||
fn current_exe_dir() -> Option<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(Path::to_path_buf))
|
||||
fn push_current_exe_resource_roots(roots: &mut Vec<PathBuf>, current_exe: &Path) {
|
||||
let Some(exe_dir) = current_exe.parent() else {
|
||||
return;
|
||||
};
|
||||
|
||||
push_resource_root(roots, exe_dir.to_path_buf());
|
||||
push_resource_root(roots, exe_dir.join("resources"));
|
||||
if let Some(resource_dir) = macos_app_resources_dir(current_exe) {
|
||||
push_resource_root(roots, resource_dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn macos_app_resources_dir(executable: &Path) -> Option<PathBuf> {
|
||||
let macos_dir = executable.parent()?;
|
||||
if macos_dir.file_name() != Some(OsStr::new("MacOS")) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let contents_dir = macos_dir.parent()?;
|
||||
if contents_dir.file_name() != Some(OsStr::new("Contents")) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let app_dir = contents_dir.parent()?;
|
||||
if app_dir.extension() != Some(OsStr::new("app")) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(contents_dir.join("Resources"))
|
||||
}
|
||||
|
||||
fn push_resource_root(roots: &mut Vec<PathBuf>, root: PathBuf) {
|
||||
@@ -68,12 +95,8 @@ mod tests {
|
||||
fn includes_windows_install_locations() {
|
||||
let local_app_data = PathBuf::from(r"C:\Users\alex\AppData\Local");
|
||||
let install_dir = local_app_data.join("Tolaria");
|
||||
let roots = runtime_resource_roots_for_env(
|
||||
None,
|
||||
None,
|
||||
Some(install_dir.clone()),
|
||||
Some(local_app_data.clone()),
|
||||
);
|
||||
let roots =
|
||||
runtime_resource_roots_for_env_and_exe(None, None, Some(local_app_data.clone()), None);
|
||||
|
||||
assert_eq!(roots.iter().filter(|root| *root == &install_dir).count(), 1);
|
||||
assert!(roots.contains(&local_app_data.join("tolaria")));
|
||||
@@ -82,4 +105,20 @@ mod tests {
|
||||
super::super::mcp_server_dir_candidates(Path::new("/repo/mcp-server"), &roots);
|
||||
assert!(candidates.contains(&install_dir.join("mcp-server")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_macos_app_bundle_resources_from_executable_path() {
|
||||
let executable = PathBuf::from("/Applications/Tolaria.app/Contents/MacOS/Tolaria");
|
||||
let roots = runtime_resource_roots_for_env_and_exe(None, None, None, Some(&executable));
|
||||
|
||||
assert!(roots.contains(&PathBuf::from(
|
||||
"/Applications/Tolaria.app/Contents/Resources"
|
||||
)));
|
||||
|
||||
let candidates =
|
||||
super::super::mcp_server_dir_candidates(Path::new("/repo/mcp-server"), &roots);
|
||||
assert!(candidates.contains(&PathBuf::from(
|
||||
"/Applications/Tolaria.app/Contents/Resources/mcp-server"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||