Compare commits
72 Commits
v2026-06-0
...
codex/mobi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3146e535f | ||
|
|
97f6a8c2b4 | ||
|
|
456d6f9887 | ||
|
|
814b8ad586 | ||
|
|
c76ef01eba | ||
|
|
453c68a7a3 | ||
|
|
a367293d9a | ||
|
|
8eaf5a530d | ||
|
|
a4c3c00567 | ||
|
|
72ea59de0b | ||
|
|
e6e616267b | ||
|
|
7a82caa80c | ||
|
|
8fc27e15da | ||
|
|
66dc461c0f | ||
|
|
7e0914c147 | ||
|
|
b7ab2041f6 | ||
|
|
2fb5b96313 | ||
|
|
e7fada7805 | ||
|
|
72f526e3f8 | ||
|
|
0f33e8958b | ||
|
|
fc24575af0 | ||
|
|
217481dcee | ||
|
|
36560a7b8c | ||
|
|
8af4652f56 | ||
|
|
6540d70f34 | ||
|
|
68723c0e96 | ||
|
|
987bb3dfc3 | ||
|
|
d60b7b5140 | ||
|
|
34044a66db | ||
|
|
a337ddc9ea | ||
|
|
1861721e9a | ||
|
|
4b3c06946c | ||
|
|
0b50144194 | ||
|
|
6589b46cb7 | ||
|
|
9eb86ab626 | ||
|
|
63235b83a4 | ||
|
|
d08da1c7bf | ||
|
|
6cf7b4b118 | ||
|
|
bb88528020 | ||
|
|
bb1ab0df2c | ||
|
|
428097f35d | ||
|
|
a41be6f58d | ||
|
|
111aa4fdd1 | ||
|
|
4db990a901 | ||
|
|
19e017b4c6 | ||
|
|
46aa43c1cd | ||
|
|
1f66fbda59 | ||
|
|
5da79cac74 | ||
|
|
0efad2211a | ||
|
|
518b61201b | ||
|
|
610e8c9703 | ||
|
|
38805b0eaf | ||
|
|
bd8e498505 | ||
|
|
f7eea2d997 | ||
|
|
035adceeae | ||
|
|
3ff99adb3d | ||
|
|
b6f227abb2 | ||
|
|
39b1118dc1 | ||
|
|
a46cb21e2f | ||
|
|
3b94faf034 | ||
|
|
165ec6f2b0 | ||
|
|
601306afb5 | ||
|
|
16de4e529b | ||
|
|
82545acbaa | ||
|
|
a122a11d46 | ||
|
|
5afd1ca62a | ||
|
|
e0887c6d71 | ||
|
|
ef544006d8 | ||
|
|
30d4d55b4a | ||
|
|
a2da4c0563 | ||
|
|
b9311ee1f2 | ||
|
|
9063b47252 |
17
.codacy.yaml
17
.codacy.yaml
@@ -1,17 +0,0 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "coverage/**"
|
||||
- "dist/**"
|
||||
- "e2e/**"
|
||||
- "node_modules/**"
|
||||
- "scripts/**"
|
||||
- "src/test/**"
|
||||
- "src-tauri/gen/**"
|
||||
- "src-tauri/resources/agent-docs/**"
|
||||
- "src-tauri/target/**"
|
||||
- "target/**"
|
||||
- "test-results/**"
|
||||
- "tests/**"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "vite.config.ts"
|
||||
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.95
|
||||
AVERAGE_THRESHOLD=9.92
|
||||
|
||||
115
.github/scripts/configure-windows-authenticode.ps1
vendored
115
.github/scripts/configure-windows-authenticode.ps1
vendored
@@ -1,115 +0,0 @@
|
||||
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."
|
||||
117
.github/workflows/ci.yml
vendored
117
.github/workflows/ci.yml
vendored
@@ -14,12 +14,10 @@ permissions:
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
# Keep large production frontend builds below CI runner memory limits.
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
jobs:
|
||||
frontend-quality:
|
||||
name: Frontend Tests & Quality Checks
|
||||
test:
|
||||
name: Tests & Quality Checks
|
||||
runs-on: macos-15
|
||||
|
||||
steps:
|
||||
@@ -28,7 +26,7 @@ jobs:
|
||||
fetch-depth: 0 # Full history for CodeScene
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -38,25 +36,38 @@ jobs:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy, llvm-tools-preview
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Keep frontend and Rust quality gates in separate macOS jobs so the
|
||||
# expensive Rust target cache restore no longer blocks the frontend lane.
|
||||
# ── 0. Build check (catches type errors and bundler failures) ─────────
|
||||
- name: TypeScript type check
|
||||
run: pnpm exec tsc --noEmit
|
||||
|
||||
- name: Vite build check
|
||||
# TypeScript is checked explicitly above; run Vite directly here to avoid
|
||||
# paying for the package build script's duplicate `tsc -b` pass.
|
||||
run: pnpm exec vite build
|
||||
|
||||
- name: Docs build check
|
||||
run: pnpm docs:build
|
||||
run: pnpm build
|
||||
|
||||
# ── 1. Coverage-backed tests ──────────────────────────────────────────
|
||||
# The coverage command runs the canonical frontend test suite.
|
||||
# The coverage commands run the same frontend and Rust test suites, so keep
|
||||
# them as the canonical test lane instead of running every suite twice.
|
||||
- name: Bundle MCP server resources (required by Tauri build)
|
||||
run: node scripts/bundle-mcp-server.mjs
|
||||
|
||||
@@ -65,15 +76,25 @@ jobs:
|
||||
run: pnpm test:coverage
|
||||
# Thresholds configured in vite.config.ts — exits non-zero if coverage drops
|
||||
|
||||
- name: Upload frontend coverage to Codecov
|
||||
- name: Rust tests + coverage (≥85% lines)
|
||||
run: |
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
|
||||
--lcov \
|
||||
--output-path coverage/rust.lcov \
|
||||
--fail-under-lines 85
|
||||
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
|
||||
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
disable_search: true
|
||||
files: ./coverage/lcov.info
|
||||
flags: frontend
|
||||
files: ./coverage/lcov.info,./coverage/rust.lcov
|
||||
verbose: true
|
||||
# OIDC avoids long-lived CODECOV_TOKEN secrets.
|
||||
|
||||
@@ -137,56 +158,6 @@ jobs:
|
||||
- name: Lint frontend
|
||||
run: pnpm lint
|
||||
|
||||
rust-quality:
|
||||
name: Rust Tests & Quality Checks
|
||||
runs-on: macos-15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
with:
|
||||
components: rustfmt, clippy, llvm-tools-preview
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@e5de28abeb52d916c5e5875d54b21a9e738b61ec
|
||||
|
||||
- name: Rust tests + coverage (≥85% lines)
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
|
||||
--lcov \
|
||||
--output-path coverage/rust.lcov \
|
||||
--fail-under-lines 85
|
||||
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
|
||||
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
|
||||
|
||||
- name: Upload Rust coverage to Codecov
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe
|
||||
with:
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
disable_search: true
|
||||
files: ./coverage/rust.lcov
|
||||
flags: rust
|
||||
verbose: true
|
||||
# OIDC avoids long-lived CODECOV_TOKEN secrets.
|
||||
|
||||
- name: Clippy (Rust)
|
||||
run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
@@ -195,11 +166,6 @@ jobs:
|
||||
|
||||
linux-build:
|
||||
name: Linux build verification
|
||||
# Keep the normal push CI lane under the 10-minute target. The release
|
||||
# workflows already perform the full Linux/AppImage build after main
|
||||
# pushes, so this slower compatibility check stays available for PRs and
|
||||
# manual diagnostics without blocking every direct push.
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
@@ -214,14 +180,13 @@ jobs:
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libfuse2 \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -232,7 +197,7 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
|
||||
104
.github/workflows/deploy-docs.yml
vendored
104
.github/workflows/deploy-docs.yml
vendored
@@ -1,104 +0,0 @@
|
||||
name: Deploy docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/deploy-docs.yml"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "scripts/build-agent-docs.mjs"
|
||||
- "site/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build VitePress site
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
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
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs and download pages
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
pnpm docs:build
|
||||
|
||||
DIST="site/.vitepress/dist"
|
||||
mkdir -p "$DIST/alpha" "$DIST/stable" "$DIST/download" "$DIST/releases" "$DIST/stable/download"
|
||||
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > "$DIST/releases.json"
|
||||
|
||||
STABLE_TAG="$(gh release list --repo ${{ github.repository }} --limit 100 --json tagName,isDraft,isPrerelease --jq '[.[] | select(.isDraft == false and .isPrerelease == false)][0].tagName // ""')"
|
||||
if [ -n "$STABLE_TAG" ]; then
|
||||
gh release download --repo ${{ github.repository }} "$STABLE_TAG" --pattern "stable-latest.json" --output "$DIST/stable/latest.json" || echo '{}' > "$DIST/stable/latest.json"
|
||||
else
|
||||
echo '{}' > "$DIST/stable/latest.json"
|
||||
fi
|
||||
|
||||
ALPHA_TAG="$(gh release list --repo ${{ github.repository }} --limit 100 --json tagName,isDraft,isPrerelease --jq '[.[] | select(.isDraft == false and .isPrerelease == true)][0].tagName // ""')"
|
||||
if [ -n "$ALPHA_TAG" ]; then
|
||||
gh release download --repo ${{ github.repository }} "$ALPHA_TAG" --pattern "alpha-latest.json" --output "$DIST/alpha/latest.json" || echo '{}' > "$DIST/alpha/latest.json"
|
||||
else
|
||||
echo '{}' > "$DIST/alpha/latest.json"
|
||||
fi
|
||||
|
||||
bun scripts/build-release-download-page.ts --latest-json "$DIST/stable/latest.json" --releases-json "$DIST/releases.json" --output-file "$DIST/download/index.html"
|
||||
bun scripts/build-release-history-page.ts --releases-json "$DIST/releases.json" --output-file "$DIST/releases/index.html"
|
||||
cp "$DIST/download/index.html" "$DIST/stable/download/index.html"
|
||||
cp "$DIST/alpha/latest.json" "$DIST/latest.json"
|
||||
cp "$DIST/alpha/latest.json" "$DIST/latest-canary.json"
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: site/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
567
.github/workflows/release-build-artifacts.yml
vendored
567
.github/workflows/release-build-artifacts.yml
vendored
@@ -1,567 +0,0 @@
|
||||
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
|
||||
556
.github/workflows/release-stable.yml
vendored
556
.github/workflows/release-stable.yml
vendored
@@ -4,7 +4,10 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'stable-v*'
|
||||
- 'v20*'
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
|
||||
concurrency:
|
||||
group: release-stable-${{ github.ref }}
|
||||
@@ -31,24 +34,14 @@ jobs:
|
||||
from datetime import date
|
||||
|
||||
tag = os.environ["GITHUB_REF_NAME"]
|
||||
legacy_match = re.fullmatch(r"stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})", tag)
|
||||
date_match = re.fullmatch(r"v(\d{4})-(\d{2})-(\d{2})", tag)
|
||||
|
||||
if date_match:
|
||||
year, month, day = map(int, date_match.groups())
|
||||
date(year, month, day)
|
||||
version = f"{year}.{month}.{day}"
|
||||
display_version = tag
|
||||
elif legacy_match:
|
||||
year, month, day = map(int, legacy_match.groups())
|
||||
date(year, month, day)
|
||||
version = f"{year}.{month}.{day}"
|
||||
display_version = version
|
||||
else:
|
||||
raise SystemExit(f"Stable tags must use vYYYY-MM-DD or stable-vYYYY.M.D, got {tag}")
|
||||
version = tag.removeprefix("stable-v")
|
||||
match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})", version)
|
||||
if not match:
|
||||
raise SystemExit(f"Stable tags must use stable-vYYYY.M.D, got {tag}")
|
||||
|
||||
date(*map(int, match.groups()))
|
||||
print(f"version={version}")
|
||||
print(f"display_version={display_version}")
|
||||
print(f"display_version={version}")
|
||||
print(f"tag={tag}")
|
||||
PY
|
||||
|
||||
@@ -56,32 +49,464 @@ jobs:
|
||||
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
|
||||
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Phase 2: Build shared release artifacts
|
||||
# -------------------------------------------------------------
|
||||
build-artifacts:
|
||||
name: Build release artifacts
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build release bundles in parallel
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
needs: version
|
||||
uses: ./.github/workflows/release-build-artifacts.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
macos_bundles: ""
|
||||
upload_macos_dmg: true
|
||||
# One-time stable promotion exceptions while Windows certificate secrets
|
||||
# are still being provisioned. Future stable tags must keep Authenticode.
|
||||
require_windows_authenticode: ${{ !contains(fromJson('["v2026-06-01","v2026-06-06"]'), needs.version.outputs.tag) }}
|
||||
secrets: inherit
|
||||
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@v4
|
||||
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@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
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 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
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@stable
|
||||
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:
|
||||
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
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
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 [ ${#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@v4
|
||||
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@stable
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (stable)
|
||||
needs: [version, build-artifacts]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -140,23 +565,16 @@ jobs:
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
NOTES_FILE="release-notes/${{ needs.version.outputs.tag }}.md"
|
||||
if [ -f "$NOTES_FILE" ]; then
|
||||
cat "$NOTES_FILE" > release_notes.md
|
||||
PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
else
|
||||
PREV_TAG=$(git for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags/v20* refs/tags/stable-v* | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
else
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
|
||||
fi
|
||||
{
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
|
||||
} > release_notes.md
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
|
||||
fi
|
||||
{
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "**Stable release — manually promoted from \`main\`**"
|
||||
@@ -164,7 +582,7 @@ jobs:
|
||||
echo "**Includes macOS (Apple Silicon and Intel), Windows x64, and Linux x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
|
||||
} >> release_notes.md
|
||||
} > release_notes.md
|
||||
|
||||
- name: Build stable-latest.json
|
||||
run: |
|
||||
@@ -243,7 +661,7 @@ jobs:
|
||||
echo "stable-latest.json:"; cat stable-latest.json
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Tolaria ${{ needs.version.outputs.display_version }}
|
||||
@@ -286,18 +704,46 @@ jobs:
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Trigger the main-branch GitHub Pages deployment
|
||||
# Phase 4: Update GitHub Pages
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update docs and release pages
|
||||
name: Update release history page
|
||||
needs: [version, release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Dispatch docs deployment from main
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Build release history page
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh workflow run deploy-docs.yml --repo ${{ github.repository }} --ref main
|
||||
echo "Triggered deploy-docs.yml on main after publishing ${{ needs.version.outputs.tag }}."
|
||||
mkdir -p _site/alpha _site/stable
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
|
||||
mkdir -p _site/download
|
||||
cp _site/stable/download/index.html _site/download/index.html
|
||||
|
||||
cp _site/alpha/latest.json _site/latest.json
|
||||
cp _site/alpha/latest.json _site/latest-canary.json
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site
|
||||
commit_message: "Update release history for ${{ needs.version.outputs.tag }}"
|
||||
|
||||
541
.github/workflows/release.yml
vendored
541
.github/workflows/release.yml
vendored
@@ -4,11 +4,10 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- ".husky/**"
|
||||
- ".github/workflows/deploy-docs.yml"
|
||||
- ".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
|
||||
|
||||
concurrency:
|
||||
group: release-alpha-${{ github.ref }}
|
||||
@@ -70,18 +69,10 @@ jobs:
|
||||
else:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
stable_date = None
|
||||
stable_patterns = (
|
||||
re.compile(r"^v(\d{4})-(\d{2})-(\d{2})$"),
|
||||
re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$"),
|
||||
)
|
||||
stable_pattern = re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$")
|
||||
|
||||
stable_tags = lines([
|
||||
"git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)",
|
||||
"refs/tags/v20*", "refs/tags/stable-v*",
|
||||
])
|
||||
|
||||
for stable_tag in stable_tags:
|
||||
match = next((pattern.fullmatch(stable_tag) for pattern in stable_patterns if pattern.fullmatch(stable_tag)), None)
|
||||
for stable_tag in lines(["git", "tag", "--list", "stable-v*", "--sort=-version:refname"]):
|
||||
match = stable_pattern.fullmatch(stable_tag)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
@@ -116,31 +107,460 @@ jobs:
|
||||
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
|
||||
echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Phase 2: Build shared release artifacts
|
||||
# -------------------------------------------------------------
|
||||
build-artifacts:
|
||||
name: Build release artifacts
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
# tauri build handles signing automatically via env vars
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
needs: version
|
||||
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
|
||||
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@v4
|
||||
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@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
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 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
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@stable
|
||||
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:
|
||||
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
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
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 [ ${#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@v4
|
||||
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@stable
|
||||
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-artifacts]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -294,7 +714,7 @@ jobs:
|
||||
echo "alpha-latest.json:"; cat alpha-latest.json
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Tolaria ${{ needs.version.outputs.display_version }}
|
||||
@@ -335,77 +755,46 @@ jobs:
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages with docs, release history, and download assets
|
||||
# Phase 4: Update GitHub Pages with release history
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update docs and release pages
|
||||
name: Update release history page
|
||||
needs: [version, release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
contents: write
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: false
|
||||
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
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs and release pages
|
||||
- name: Build release history page
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VITEPRESS_BASE="/" pnpm docs:build
|
||||
mkdir -p _site/alpha _site/stable _site/release-notes
|
||||
cp -R site/.vitepress/dist/. _site/
|
||||
if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi
|
||||
mkdir -p _site/alpha _site/stable
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
STABLE_TAG=$(gh release list --repo ${{ github.repository }} --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // ""')
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
if [ -n "$STABLE_TAG" ]; then
|
||||
gh release download --repo ${{ github.repository }} "$STABLE_TAG" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
else
|
||||
echo '{}' > _site/stable/latest.json
|
||||
fi
|
||||
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
|
||||
mkdir -p _site/download
|
||||
cp _site/stable/download/index.html _site/download/index.html
|
||||
|
||||
cp _site/alpha/latest.json _site/latest.json
|
||||
cp _site/alpha/latest.json _site/latest-canary.json
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: ./_site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site
|
||||
commit_message: "Update release history for ${{ needs.version.outputs.tag }}"
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -10,9 +10,6 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
site/.vitepress/cache/
|
||||
site/.vitepress/dist/
|
||||
_site/
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
@@ -45,7 +42,7 @@ final_selection.py
|
||||
src-tauri/target
|
||||
|
||||
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
|
||||
src-tauri/resources/mcp-server/
|
||||
src-tauri/resources/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
@@ -76,6 +73,3 @@ CODE-HEALTH-REPORT.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Local Codacy CLI runtime/config generated by the MCP server
|
||||
.codacy/
|
||||
|
||||
@@ -28,11 +28,16 @@ require_main_branch() {
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$CURRENT_BRANCH" != "main" ]; then
|
||||
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
|
||||
echo " Merge or cherry-pick your work onto main, then commit there."
|
||||
exit 1
|
||||
fi
|
||||
case "$CURRENT_BRANCH" in
|
||||
main|codex/mobile)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
|
||||
echo " Merge or cherry-pick your work onto main, then commit there."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_main_branch
|
||||
@@ -40,36 +45,8 @@ ensure_node_tooling
|
||||
|
||||
echo "🔍 Pre-commit checks..."
|
||||
|
||||
STAGED_FILES=$(git diff --cached --name-only)
|
||||
APP_CHANGED=false
|
||||
SITE_CHANGED=false
|
||||
|
||||
for FILE in $STAGED_FILES; do
|
||||
case "$FILE" in
|
||||
site/*)
|
||||
SITE_CHANGED=true
|
||||
;;
|
||||
.github/workflows/*|.husky/*|docs/*|*.md)
|
||||
;;
|
||||
*)
|
||||
APP_CHANGED=true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$APP_CHANGED" = false ]; then
|
||||
if [ "$SITE_CHANGED" = true ]; then
|
||||
echo " → docs build..."
|
||||
pnpm docs:build
|
||||
else
|
||||
echo " → app checks skipped (docs/workflow/hooks only)"
|
||||
fi
|
||||
echo "✅ Pre-commit passed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Lint + types (only if TS files staged)
|
||||
STAGED_TS=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx)$' || true)
|
||||
STAGED_TS=$(git diff --cached --name-only | grep -E '\.(ts|tsx)$' || true)
|
||||
if [ -n "$STAGED_TS" ]; then
|
||||
echo " → lint + tsc..."
|
||||
pnpm lint --quiet
|
||||
@@ -78,7 +55,7 @@ fi
|
||||
|
||||
# Unit tests
|
||||
echo " → tests..."
|
||||
pnpm exec vitest run --silent
|
||||
pnpm test -- --silent
|
||||
|
||||
echo "✅ Pre-commit passed"
|
||||
|
||||
|
||||
@@ -28,23 +28,6 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
set -e
|
||||
|
||||
ensure_cargo_tooling() {
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -s "$HOME/.cargo/env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.cargo/env"
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "❌ cargo must be available before pushing"
|
||||
echo " Install Rust via https://rustup.rs or ensure ~/.cargo/bin is in PATH."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_node_tooling() {
|
||||
if command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1; then
|
||||
return 0
|
||||
@@ -102,7 +85,6 @@ else
|
||||
fi
|
||||
require_main_push
|
||||
ensure_node_tooling
|
||||
ensure_cargo_tooling
|
||||
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
@@ -113,83 +95,47 @@ echo "================================================"
|
||||
# ── Detect what changed ─────────────────────────────────────────────────
|
||||
PUSH_TARGET=$(git rev-parse @{push} 2>/dev/null || echo "")
|
||||
RUST_CHANGED=true
|
||||
APP_CHANGED=true
|
||||
SITE_CHANGED=false
|
||||
|
||||
if [ -n "$PUSH_TARGET" ]; then
|
||||
CHANGED=$(git diff --name-only "$PUSH_TARGET"..HEAD)
|
||||
APP_CHANGED=false
|
||||
if ! echo "$CHANGED" | grep -qE '^(src-tauri/|Cargo)'; then
|
||||
RUST_CHANGED=false
|
||||
fi
|
||||
for FILE in $CHANGED; do
|
||||
case "$FILE" in
|
||||
site/*)
|
||||
SITE_CHANGED=true
|
||||
;;
|
||||
.github/workflows/*|.husky/*|docs/*|*.md)
|
||||
;;
|
||||
*)
|
||||
APP_CHANGED=true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$APP_CHANGED" = false ]; then
|
||||
if [ "$SITE_CHANGED" = true ]; then
|
||||
echo ""
|
||||
echo "📚 Docs-only push detected; running docs build..."
|
||||
pnpm docs:build
|
||||
echo " ✅ Docs build OK"
|
||||
else
|
||||
echo ""
|
||||
echo "⏭️ App checks skipped (docs/workflow/hooks only)"
|
||||
fi
|
||||
ELAPSED=$(($(date +%s) - START_TIME))
|
||||
echo ""
|
||||
echo "✅ Pre-push passed in ${ELAPSED}s"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 0. Frontend lint ───────────────────────────────────────────────────
|
||||
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "🔎 [0/6] Frontend lint..."
|
||||
pnpm lint
|
||||
echo " ✅ Lint OK"
|
||||
|
||||
# ── 1. TypeScript + Vite build ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📦 [1/6] TypeScript + Vite build..."
|
||||
echo "📦 [0/5] TypeScript + Vite build..."
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm build
|
||||
echo " ✅ Build OK"
|
||||
|
||||
# ── 2. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
echo ""
|
||||
echo "📊 [2/6] Frontend tests + coverage (≥70%)..."
|
||||
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
|
||||
pnpm test:coverage --silent
|
||||
echo " ✅ Frontend coverage OK"
|
||||
|
||||
# ── 3. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
echo ""
|
||||
if [ "$RUST_CHANGED" = true ]; then
|
||||
echo "🔧 [3/6] Clippy + rustfmt..."
|
||||
echo "🔧 [2/5] 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 "⏭️ [3/6] Rust lint — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 4. Rust coverage (≥85% lines) ──────────────────────────────────────
|
||||
# ── 3. 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 "🦀 [4/6] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
else
|
||||
echo "🦀 [4/6] Rust coverage (≥85%, incremental)..."
|
||||
echo "🦀 [3/5] 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
|
||||
@@ -202,24 +148,24 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
else
|
||||
echo "⏭️ [4/6] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 5. Playwright core smoke lane (if any exist) ──────────────────────
|
||||
# ── 4. 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 "🎭 [5/6] Playwright core smoke tests..."
|
||||
echo "🎭 [4/5] Playwright core smoke tests..."
|
||||
if ! pnpm playwright:smoke; then
|
||||
echo " ❌ Core smoke tests FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ Core smoke tests OK"
|
||||
else
|
||||
echo "⏭️ [5/6] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
|
||||
echo "⏭️ [4/5] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 6. CodeScene code health gate (ratchet) ──────────────────────────────
|
||||
# ── 5. 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.
|
||||
@@ -235,7 +181,7 @@ if [ -f "$THRESHOLDS_FILE" ]; then
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏥 [6/6] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
|
||||
echo "🏥 [5/5] 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
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
tests/
|
||||
e2e/
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
test-results/
|
||||
src-tauri/target/
|
||||
target/
|
||||
src-tauri/gen/apple/assets/mcp-server/index.js
|
||||
src-tauri/gen/apple/assets/mcp-server/ws-bridge.js
|
||||
121
AGENTS.md
121
AGENTS.md
@@ -1,22 +1,67 @@
|
||||
# AGENTS.md — Tolaria App
|
||||
|
||||
## 1. Development Process
|
||||
> Quick links: [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
|
||||
|
||||
### Start working on a task
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
**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
|
||||
- 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]`
|
||||
- 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)
|
||||
- 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")
|
||||
- Code health: final Hotspot and Average scores after push
|
||||
|
||||
---
|
||||
|
||||
## 2. Development Process
|
||||
|
||||
### 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**
|
||||
|
||||
@@ -46,8 +91,6 @@ When adding or changing a meaningful user-facing feature, include the event name
|
||||
|
||||
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
||||
|
||||
**Release rule:** CodeScene is a before/after gate, not just a final score. Every task must record the starting CodeScene state before edits and the final state after edits. If touched code gets worse, refactor before committing.
|
||||
|
||||
**⛔ NEVER edit `.codescene-thresholds` to lower the values.** If the gate blocks you, improve the code — do not lower the bar.
|
||||
|
||||
**CodeScene access order:** use CodeScene MCP tools if available. If MCP is unavailable, use the installed `cs` CLI for file-level review/delta work, and use the CodeScene API (`CODESCENE_PAT` + `CODESCENE_PROJECT_ID`) for project-wide Hotspot/Average threshold checks from `.codescene-thresholds`.
|
||||
@@ -60,70 +103,12 @@ Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code
|
||||
|
||||
**If CodeScene gate blocks your push:** use `mcp__codescene__code_health_score` to find the worst file, refactor it, commit, push again. Do NOT stop or wait for laputa-refactor — that is a background loop, not a substitute for fixing your own regressions.
|
||||
|
||||
### Security scan with Codacy (mandatory)
|
||||
|
||||
Use Codacy as a security and static-analysis gate before a task is considered releasable.
|
||||
|
||||
- Prefer the Codacy MCP inside Codex to inspect repository/file issues for every touched code file.
|
||||
- If MCP is unavailable, use the local CLI wrapper, e.g. `.codacy/cli.sh analyze <path> --format sarif`; choose the relevant tool when useful (`eslint`, `opengrep`, `trivy`, `lizard`).
|
||||
- **Always fix Critical and High severity findings introduced by your change.** Do not move the task to In Review with new Critical/High Codacy issues.
|
||||
- Review Medium findings. Fix them when they are real defects or security-sensitive; otherwise explain why they are acceptable in the completion comment.
|
||||
- Never silence a Codacy rule just to pass the scan. Prefer small code changes that remove the finding.
|
||||
|
||||
### Check suite (runs on every push)
|
||||
```bash
|
||||
pnpm lint && npx tsc --noEmit && pnpm test && pnpm test:coverage # frontend ≥70%
|
||||
cargo test && cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
|
||||
```
|
||||
|
||||
Coverage is a release gate, not a vanity metric:
|
||||
- Frontend coverage must stay ≥70%.
|
||||
- Rust line coverage must stay ≥85%.
|
||||
- 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 the release gates and 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; 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: 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
|
||||
|
||||
ADRs live in `docs/adr/`. Create in the same commit as the code. Never edit existing — create a new one that supersedes. Use `/create-adr`. **When:** new dependency, storage strategy, platform target, core abstraction, cross-cutting pattern. **Not for:** bug fixes, styling, refactors.
|
||||
@@ -132,7 +117,7 @@ After any Tauri command, new component/hook, data model change, or new integrati
|
||||
|
||||
---
|
||||
|
||||
## 2. Product Rules
|
||||
## 3. Product Rules
|
||||
|
||||
### Demo vault hygiene (`demo-vault/`, `demo-vault-v2/`)
|
||||
|
||||
@@ -172,7 +157,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
||||
|
||||
---
|
||||
|
||||
## 3. Reference
|
||||
## 4. Reference
|
||||
|
||||
### macOS / Tauri gotchas
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
This file is only a Gemini CLI compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
@@ -1,4 +1,4 @@
|
||||
 [](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [](https://codecov.io/gh/refactoringhq/tolaria) [](https://codescene.io/projects/76865)
|
||||
 [](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [](https://codecov.io/gh/refactoringhq/tolaria) [](https://codescene.io/projects/76865)
|
||||
|
||||
# 💧 Tolaria
|
||||
|
||||
@@ -43,14 +43,12 @@ brew install --cask tolaria
|
||||
|
||||
### Download from releases
|
||||
|
||||
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.
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
|
||||
|
||||
## Getting started
|
||||
|
||||
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
|
||||
|
||||
The public user docs live in [`site/`](site/) and are published to GitHub Pages. Start with [Install Tolaria](site/start/install.md), then [First Launch](site/start/first-launch.md).
|
||||
|
||||
## Open source and local setup
|
||||
|
||||
Tolaria is open source and built with Tauri, React, and TypeScript. If you want to run or contribute to the app locally, here is [how to get started](https://github.com/refactoringhq/tolaria/blob/main/docs/GETTING-STARTED.md). You can also find the gist below 👇
|
||||
|
||||
@@ -16,7 +16,7 @@ We currently support security fixes for:
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please use GitHub's private vulnerability reporting flow for this repository.
|
||||
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
|
||||
|
||||
Include as much of the following as you can:
|
||||
|
||||
|
||||
41
apps/mobile/.gitignore
vendored
Normal file
41
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
19
apps/mobile/App.tsx
Normal file
19
apps/mobile/App.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import { MobileApp } from './src/MobileApp'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<MobileApp />
|
||||
<StatusBar style="auto" />
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
37
apps/mobile/app.json
Normal file
37
apps/mobile/app.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Tolaria",
|
||||
"slug": "tolaria-mobile",
|
||||
"scheme": "tolaria",
|
||||
"version": "0.1.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.tolaria.mobile.dev",
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"package": "com.tolaria.mobile.dev",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-secure-store",
|
||||
"expo-web-browser"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
9
apps/mobile/index.ts
Normal file
9
apps/mobile/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'react-native-gesture-handler'
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
50
apps/mobile/metro.config.js
Normal file
50
apps/mobile/metro.config.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const path = require('node:path')
|
||||
const { getDefaultConfig } = require('expo/metro-config')
|
||||
|
||||
const projectRoot = __dirname
|
||||
const workspaceRoot = path.resolve(projectRoot, '../..')
|
||||
const config = getDefaultConfig(projectRoot)
|
||||
const mobileNodeModules = path.resolve(projectRoot, 'node_modules')
|
||||
const workspaceNodeModules = path.resolve(workspaceRoot, 'node_modules')
|
||||
const mobileReactRoot = path.resolve(mobileNodeModules, 'react')
|
||||
const mobileReactDomRoot = path.resolve(mobileNodeModules, 'react-dom')
|
||||
|
||||
config.watchFolders = [workspaceRoot]
|
||||
config.resolver.nodeModulesPaths = [
|
||||
mobileNodeModules,
|
||||
workspaceNodeModules,
|
||||
]
|
||||
config.resolver.extraNodeModules = {
|
||||
...config.resolver.extraNodeModules,
|
||||
react: mobileReactRoot,
|
||||
'react-dom': mobileReactDomRoot,
|
||||
'react-native': path.resolve(workspaceNodeModules, 'react-native'),
|
||||
}
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (moduleName === 'react' || moduleName.startsWith('react/')) {
|
||||
return context.resolveRequest(
|
||||
{ ...context, originModulePath: path.join(mobileReactRoot, 'index.js') },
|
||||
moduleName,
|
||||
platform,
|
||||
)
|
||||
}
|
||||
|
||||
if (moduleName === 'react-dom' || moduleName.startsWith('react-dom/')) {
|
||||
return context.resolveRequest(
|
||||
{ ...context, originModulePath: path.join(mobileReactDomRoot, 'index.js') },
|
||||
moduleName,
|
||||
platform,
|
||||
)
|
||||
}
|
||||
|
||||
if (moduleName === 'isomorphic-git') {
|
||||
return {
|
||||
filePath: path.resolve(workspaceNodeModules, 'isomorphic-git/index.js'),
|
||||
type: 'sourceFile',
|
||||
}
|
||||
}
|
||||
|
||||
return context.resolveRequest(context, moduleName, platform)
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
44
apps/mobile/package.json
Normal file
44
apps/mobile/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@tolaria/mobile",
|
||||
"version": "0.1.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"start:dev-client": "expo start --dev-client",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"ios:dev-client": "expo run:ios --port 8091",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@10play/tentap-editor": "^1.0.1",
|
||||
"@tolaria/markdown": "workspace:*",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~54.0.34",
|
||||
"expo-auth-session": "~7.0.11",
|
||||
"expo-dev-client": "~6.0.21",
|
||||
"expo-file-system": "19.0.22",
|
||||
"expo-modules-core": "3.0.30",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.11",
|
||||
"isomorphic-git": "^1.37.6",
|
||||
"phosphor-react-native": "^3.0.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-svg": "^15.12.1",
|
||||
"react-native-webview": "13.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.17",
|
||||
"@types/react-dom": "~19.1.11",
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
148
apps/mobile/src/MobileAiPanel.tsx
Normal file
148
apps/mobile/src/MobileAiPanel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { CaretLeft, GearSix, PaperPlaneTilt, Robot } from 'phosphor-react-native'
|
||||
import { useState } from 'react'
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
import type { MobileAiProvider } from './mobileAiSettings'
|
||||
import { styles } from './styles'
|
||||
import { colors } from './theme'
|
||||
|
||||
export function MobileAiPanel({
|
||||
note,
|
||||
onClose,
|
||||
onOpenSettings,
|
||||
onSendPrompt,
|
||||
provider,
|
||||
}: {
|
||||
note: MobileNote
|
||||
onClose?: () => void
|
||||
onOpenSettings: () => void
|
||||
onSendPrompt: (prompt: string, provider: MobileAiProvider) => Promise<string>
|
||||
provider: MobileAiProvider | null
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [response, setResponse] = useState('')
|
||||
|
||||
const sendPrompt = () => {
|
||||
if (!provider || prompt.trim().length === 0) return
|
||||
|
||||
setFailed(false)
|
||||
setIsSending(true)
|
||||
void onSendPrompt(prompt, provider)
|
||||
.then(setResponse)
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setIsSending(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.properties}>
|
||||
<AiToolbar onClose={onClose} onOpenSettings={onOpenSettings} />
|
||||
{provider ? (
|
||||
<AiChatSurface
|
||||
failed={failed}
|
||||
isSending={isSending}
|
||||
note={note}
|
||||
onChangePrompt={setPrompt}
|
||||
onSend={sendPrompt}
|
||||
prompt={prompt}
|
||||
provider={provider}
|
||||
response={response}
|
||||
/>
|
||||
) : (
|
||||
<AiEmptyState onOpenSettings={onOpenSettings} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AiToolbar({
|
||||
onClose,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
onClose?: () => void
|
||||
onOpenSettings: () => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.propertiesTitle}>AI</Text>
|
||||
<View style={styles.toolbarSpacer} />
|
||||
<Pressable onPress={onOpenSettings} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
|
||||
<GearSix size={23} color={colors.textSoft} />
|
||||
</Pressable>
|
||||
{onClose ? (
|
||||
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
|
||||
<CaretLeft size={23} color={colors.textSoft} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AiEmptyState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
return (
|
||||
<View style={styles.aiEmptyState}>
|
||||
<Robot color={colors.iconMuted} size={26} />
|
||||
<Text style={styles.aiEmptyTitle}>No API model configured</Text>
|
||||
<Text style={styles.aiEmptyDescription}>Add an API model in Settings before using the AI panel.</Text>
|
||||
<Pressable onPress={onOpenSettings} style={({ pressed }) => [styles.aiSettingsButton, pressed ? styles.pressed : null]}>
|
||||
<GearSix color="#ffffff" size={17} />
|
||||
<Text style={styles.aiSettingsButtonText}>Open Settings</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AiChatSurface({
|
||||
failed,
|
||||
isSending,
|
||||
note,
|
||||
onChangePrompt,
|
||||
onSend,
|
||||
prompt,
|
||||
provider,
|
||||
response,
|
||||
}: {
|
||||
failed: boolean
|
||||
isSending: boolean
|
||||
note: MobileNote
|
||||
onChangePrompt: (value: string) => void
|
||||
onSend: () => void
|
||||
prompt: string
|
||||
provider: MobileAiProvider
|
||||
response: string
|
||||
}) {
|
||||
const canSend = prompt.trim().length > 0 && !isSending
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.aiContent}>
|
||||
<View style={styles.aiContextCard}>
|
||||
<Text style={styles.aiContextTitle}>{provider.name} · {provider.modelId}</Text>
|
||||
<Text numberOfLines={2} style={styles.aiContextDetail}>{note.title}</Text>
|
||||
</View>
|
||||
{response ? <Text style={styles.aiResponse}>{response}</Text> : <Text style={styles.aiEmptyDescription}>Ask about the active note. API models run in chat mode only.</Text>}
|
||||
{failed ? <Text style={styles.propertyError}>AI request failed.</Text> : null}
|
||||
<TextInput
|
||||
multiline
|
||||
onChangeText={onChangePrompt}
|
||||
placeholder={`Ask about ${note.title}`}
|
||||
placeholderTextColor={colors.mutedText}
|
||||
style={styles.aiPrompt}
|
||||
textAlignVertical="top"
|
||||
value={prompt}
|
||||
/>
|
||||
<Pressable
|
||||
disabled={!canSend}
|
||||
onPress={onSend}
|
||||
style={({ pressed }) => [
|
||||
styles.aiSendButton,
|
||||
!canSend ? styles.composeButtonDisabled : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<PaperPlaneTilt color="#ffffff" size={18} weight="fill" />
|
||||
<Text style={styles.aiSendButtonText}>{isSending ? 'Sending' : 'Send'}</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
197
apps/mobile/src/MobileAiSettingsPanel.tsx
Normal file
197
apps/mobile/src/MobileAiSettingsPanel.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { CaretLeft, Trash } from 'phosphor-react-native'
|
||||
import { useState } from 'react'
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
|
||||
import {
|
||||
mobileAiProviderPresets,
|
||||
type MobileAiProvider,
|
||||
type MobileAiProviderDraft,
|
||||
type MobileAiProviderKind,
|
||||
type MobileAiSettings,
|
||||
} from './mobileAiSettings'
|
||||
import { styles } from './styles'
|
||||
import { colors } from './theme'
|
||||
|
||||
const providerKinds: MobileAiProviderKind[] = ['open_ai', 'anthropic', 'gemini', 'open_router', 'open_ai_compatible']
|
||||
|
||||
export function MobileAiSettingsPanel({
|
||||
failed,
|
||||
isSaving,
|
||||
onAddProvider,
|
||||
onClose,
|
||||
onRemoveProvider,
|
||||
settings,
|
||||
}: {
|
||||
failed: boolean
|
||||
isSaving: boolean
|
||||
onAddProvider: (draft: MobileAiProviderDraft) => Promise<boolean>
|
||||
onClose?: () => void
|
||||
onRemoveProvider: (providerId: string) => Promise<boolean>
|
||||
settings: MobileAiSettings
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.properties}>
|
||||
<SettingsToolbar onClose={onClose} />
|
||||
<ScrollView contentContainerStyle={styles.aiSettingsContent}>
|
||||
<Text style={styles.aiSettingsSectionTitle}>API models</Text>
|
||||
<Text style={styles.aiSettingsDescription}>API keys are saved locally on this device and are not written to vault settings.</Text>
|
||||
<ProviderList providers={settings.providers} onRemoveProvider={onRemoveProvider} />
|
||||
<ProviderDraftForm failed={failed} isSaving={isSaving} onAddProvider={onAddProvider} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderDraftForm({
|
||||
failed,
|
||||
isSaving,
|
||||
onAddProvider,
|
||||
}: {
|
||||
failed: boolean
|
||||
isSaving: boolean
|
||||
onAddProvider: (draft: MobileAiProviderDraft) => Promise<boolean>
|
||||
}) {
|
||||
const [draft, setDraft] = useState<MobileAiProviderDraft>(() => initialDraft('open_ai'))
|
||||
const canSave = isProviderDraftReady(draft)
|
||||
const isDisabled = !canSave || isSaving
|
||||
const submitProvider = () => {
|
||||
if (isDisabled) return
|
||||
|
||||
void onAddProvider(draft).then((saved) => {
|
||||
if (saved) {
|
||||
setDraft(initialDraft(draft.kind))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProviderKindPicker value={draft.kind} onChange={(kind) => setDraft(initialDraft(kind))} />
|
||||
<TextInput
|
||||
onChangeText={(modelId) => setDraft((current) => ({ ...current, modelId }))}
|
||||
placeholder={mobileAiProviderPresets[draft.kind].placeholder}
|
||||
placeholderTextColor={colors.mutedText}
|
||||
style={styles.aiInput}
|
||||
value={draft.modelId}
|
||||
/>
|
||||
<TextInput
|
||||
onChangeText={(apiKey) => setDraft((current) => ({ ...current, apiKey }))}
|
||||
placeholder="API key"
|
||||
placeholderTextColor={colors.mutedText}
|
||||
secureTextEntry
|
||||
style={styles.aiInput}
|
||||
value={draft.apiKey}
|
||||
/>
|
||||
<ProviderSaveError failed={failed} />
|
||||
<ProviderSaveButton disabled={isDisabled} isSaving={isSaving} onPress={submitProvider} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderSaveError({ failed }: { failed: boolean }) {
|
||||
return failed ? <Text style={styles.propertyError}>Could not save AI settings.</Text> : null
|
||||
}
|
||||
|
||||
function ProviderSaveButton({
|
||||
disabled,
|
||||
isSaving,
|
||||
onPress,
|
||||
}: {
|
||||
disabled: boolean
|
||||
isSaving: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.aiSettingsButton,
|
||||
disabled ? styles.composeButtonDisabled : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.aiSettingsButtonText}>{isSaving ? 'Saving' : 'Add API model'}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function isProviderDraftReady(draft: MobileAiProviderDraft) {
|
||||
return draft.modelId.trim().length > 0 && draft.apiKey.trim().length > 0
|
||||
}
|
||||
|
||||
function SettingsToolbar({ onClose }: { onClose?: () => void }) {
|
||||
return (
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.propertiesTitle}>Settings</Text>
|
||||
<View style={styles.toolbarSpacer} />
|
||||
{onClose ? (
|
||||
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
|
||||
<CaretLeft size={23} color={colors.textSoft} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderList({
|
||||
onRemoveProvider,
|
||||
providers,
|
||||
}: {
|
||||
onRemoveProvider: (providerId: string) => void
|
||||
providers: MobileAiProvider[]
|
||||
}) {
|
||||
if (providers.length === 0) {
|
||||
return <Text style={styles.aiSettingsEmpty}>No API models configured.</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.aiProviderList}>
|
||||
{providers.map((provider) => (
|
||||
<View key={provider.id} style={styles.aiProviderRow}>
|
||||
<View style={styles.aiProviderText}>
|
||||
<Text style={styles.aiProviderTitle}>{provider.name}</Text>
|
||||
<Text numberOfLines={1} style={styles.aiProviderDetail}>{provider.modelId}</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => onRemoveProvider(provider.id)} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
|
||||
<Trash size={18} color={colors.textSoft} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderKindPicker({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
onChange: (value: MobileAiProviderKind) => void
|
||||
value: MobileAiProviderKind
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.aiProviderKindRow}>
|
||||
{providerKinds.map((kind) => (
|
||||
<Pressable
|
||||
key={kind}
|
||||
onPress={() => onChange(kind)}
|
||||
style={({ pressed }) => [
|
||||
styles.aiProviderKindChip,
|
||||
value === kind ? styles.aiProviderKindChipSelected : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.aiProviderKindText, value === kind ? styles.aiProviderKindTextSelected : null]}>{mobileAiProviderPresets[kind].name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function initialDraft(kind: MobileAiProviderKind): MobileAiProviderDraft {
|
||||
return {
|
||||
apiKey: '',
|
||||
kind,
|
||||
modelId: '',
|
||||
name: mobileAiProviderPresets[kind].name,
|
||||
}
|
||||
}
|
||||
1109
apps/mobile/src/MobileApp.tsx
Normal file
1109
apps/mobile/src/MobileApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
482
apps/mobile/src/MobileEditablePropertyPickers.tsx
Normal file
482
apps/mobile/src/MobileEditablePropertyPickers.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import { CaretDown, CaretRight } from 'phosphor-react-native'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import type { MobileNote } from './demoData'
|
||||
import { NamedIcon, type IconName } from './NamedIcon'
|
||||
import {
|
||||
formatMobileNoteTags,
|
||||
isMobileNotePropertySelected,
|
||||
mobileNoteIconOptions,
|
||||
mobileNoteStatusOptions,
|
||||
mobileNoteTagOptions,
|
||||
mobileNoteTypeOptions,
|
||||
parseMobileNoteTags,
|
||||
toggleMobileNoteTag,
|
||||
type MobileNotePropertyPatch,
|
||||
} from './mobileNoteProperties'
|
||||
import { filterMobilePropertyComboOptions, resolveMobilePropertyComboValue } from './mobilePropertyCombo'
|
||||
import { mobilePropertyDisplayValue, type MobilePropertyPickerKey } from './mobilePropertyPicker'
|
||||
import { styles } from './styles'
|
||||
import { colors } from './theme'
|
||||
|
||||
export function MobileEditablePropertyPickers({
|
||||
disabled,
|
||||
note,
|
||||
onChangeProperties,
|
||||
onSelectPicker,
|
||||
openPicker,
|
||||
}: {
|
||||
disabled: boolean
|
||||
note: MobileNote
|
||||
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
|
||||
onSelectPicker: (selected: MobilePropertyPickerKey) => void
|
||||
openPicker: MobilePropertyPickerKey | null
|
||||
}) {
|
||||
const today = formatMobilePropertyDate(new Date())
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditableTextProperty
|
||||
disabled={disabled}
|
||||
isOpen={openPicker === 'type'}
|
||||
key={`type:${note.id}:${note.type}`}
|
||||
label="Type"
|
||||
placeholder="Type"
|
||||
suggestions={mobileNoteTypeOptions}
|
||||
value={note.type}
|
||||
onCommit={(type) => onChangeProperties?.({ type })}
|
||||
onOpen={() => onSelectPicker('type')}
|
||||
variant="combo"
|
||||
/>
|
||||
<EditableTextProperty
|
||||
disabled={disabled}
|
||||
isOpen={openPicker === 'status'}
|
||||
key={`status:${note.id}:${note.status ?? ''}`}
|
||||
label="Status"
|
||||
placeholder="Status"
|
||||
suggestions={mobileNoteStatusOptions}
|
||||
value={note.status ?? ''}
|
||||
onCommit={(status) => onChangeProperties?.({ status })}
|
||||
onOpen={() => onSelectPicker('status')}
|
||||
/>
|
||||
<EditableTextProperty
|
||||
disabled={disabled}
|
||||
isOpen={openPicker === 'date'}
|
||||
key={`date:${note.id}:${note.date}`}
|
||||
label="Date"
|
||||
placeholder="Date"
|
||||
suggestions={[today, '']}
|
||||
value={note.date}
|
||||
onCommit={(date) => onChangeProperties?.({ date })}
|
||||
onOpen={() => onSelectPicker('date')}
|
||||
/>
|
||||
<IconProperty
|
||||
disabled={disabled}
|
||||
isOpen={openPicker === 'icon'}
|
||||
note={note}
|
||||
onChangeProperties={onChangeProperties}
|
||||
onOpen={() => onSelectPicker('icon')}
|
||||
/>
|
||||
<TagsProperty
|
||||
disabled={disabled}
|
||||
isOpen={openPicker === 'tags'}
|
||||
key={`tags:${note.id}:${formatMobileNoteTags(note.tags)}`}
|
||||
note={note}
|
||||
onChangeProperties={onChangeProperties}
|
||||
onOpen={() => onSelectPicker('tags')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EditableTextProperty({
|
||||
disabled,
|
||||
isOpen,
|
||||
label,
|
||||
onCommit,
|
||||
onOpen,
|
||||
placeholder,
|
||||
suggestions,
|
||||
value,
|
||||
variant = 'chips',
|
||||
}: {
|
||||
disabled: boolean
|
||||
isOpen: boolean
|
||||
label: string
|
||||
onCommit: (value: string) => void
|
||||
onOpen: () => void
|
||||
placeholder: string
|
||||
suggestions: readonly string[]
|
||||
value: string
|
||||
variant?: 'chips' | 'combo'
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value)
|
||||
const commitDraft = () => {
|
||||
const next = variant === 'combo'
|
||||
? resolveMobilePropertyComboValue({ options: suggestions, value: draft })
|
||||
: draft
|
||||
commitTextValue({ current: value, next, onCommit, onSettled: setDraft })
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyPickerSection
|
||||
disabled={disabled}
|
||||
isOpen={isOpen}
|
||||
label={label}
|
||||
value={mobilePropertyDisplayValue({ value })}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<View style={styles.propertyPickerOptions}>
|
||||
<TextInput
|
||||
autoCapitalize="sentences"
|
||||
autoFocus={variant === 'combo'}
|
||||
editable={!disabled}
|
||||
keyboardType="default"
|
||||
onBlur={commitDraft}
|
||||
onChangeText={setDraft}
|
||||
onSubmitEditing={commitDraft}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.mutedText}
|
||||
returnKeyType="done"
|
||||
selectTextOnFocus={variant === 'combo'}
|
||||
style={styles.propertyTextInput}
|
||||
value={draft}
|
||||
/>
|
||||
{variant === 'combo'
|
||||
? (
|
||||
<PropertyComboOptions
|
||||
disabled={disabled}
|
||||
query={draft}
|
||||
suggestions={suggestions}
|
||||
value={value}
|
||||
onSelect={(selected) => {
|
||||
setDraft(selected)
|
||||
onCommit(selected)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<PropertyChipOptions>
|
||||
{suggestions.map((option) => (
|
||||
<PropertyTextChip
|
||||
disabled={disabled}
|
||||
key={option || 'none'}
|
||||
option={option}
|
||||
value={value}
|
||||
onSelect={(selected) => {
|
||||
setDraft(selected)
|
||||
onCommit(selected)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</PropertyChipOptions>
|
||||
)}
|
||||
</View>
|
||||
</PropertyPickerSection>
|
||||
)
|
||||
}
|
||||
|
||||
function IconProperty({
|
||||
disabled,
|
||||
isOpen,
|
||||
note,
|
||||
onChangeProperties,
|
||||
onOpen,
|
||||
}: {
|
||||
disabled: boolean
|
||||
isOpen: boolean
|
||||
note: MobileNote
|
||||
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
|
||||
onOpen: () => void
|
||||
}) {
|
||||
return (
|
||||
<PropertyPickerSection
|
||||
disabled={disabled}
|
||||
isOpen={isOpen}
|
||||
label="Icon"
|
||||
value={mobilePropertyDisplayValue({ value: note.icon })}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<PropertyChipOptions>
|
||||
{mobileNoteIconOptions.map((option) => (
|
||||
<PropertyIconChip
|
||||
disabled={disabled}
|
||||
key={option}
|
||||
option={option}
|
||||
value={note.icon}
|
||||
onSelect={(icon) => onChangeProperties?.({ icon })}
|
||||
/>
|
||||
))}
|
||||
</PropertyChipOptions>
|
||||
</PropertyPickerSection>
|
||||
)
|
||||
}
|
||||
|
||||
function TagsProperty({
|
||||
disabled,
|
||||
isOpen,
|
||||
note,
|
||||
onChangeProperties,
|
||||
onOpen,
|
||||
}: {
|
||||
disabled: boolean
|
||||
isOpen: boolean
|
||||
note: MobileNote
|
||||
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(formatMobileNoteTags(note.tags))
|
||||
const commitDraft = () => onChangeProperties?.({ tags: parseMobileNoteTags(draft) })
|
||||
|
||||
return (
|
||||
<PropertyPickerSection
|
||||
disabled={disabled}
|
||||
isOpen={isOpen}
|
||||
label="Tags"
|
||||
value={note.tags.length > 0 ? formatMobileNoteTags(note.tags) : 'None'}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<View style={styles.propertyPickerOptions}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
editable={!disabled}
|
||||
keyboardType="default"
|
||||
onBlur={commitDraft}
|
||||
onChangeText={setDraft}
|
||||
onSubmitEditing={commitDraft}
|
||||
placeholder="Tags"
|
||||
placeholderTextColor={colors.mutedText}
|
||||
returnKeyType="done"
|
||||
style={styles.propertyTextInput}
|
||||
value={draft}
|
||||
/>
|
||||
<PropertyChipOptions>
|
||||
{mobileNoteTagOptions.map((option) => (
|
||||
<PropertyTextChip
|
||||
disabled={disabled}
|
||||
key={option}
|
||||
option={option}
|
||||
value={note.tags}
|
||||
onSelect={(tag) => {
|
||||
const tags = toggleMobileNoteTag(note.tags, tag)
|
||||
setDraft(formatMobileNoteTags(tags))
|
||||
onChangeProperties?.({ tags })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</PropertyChipOptions>
|
||||
</View>
|
||||
</PropertyPickerSection>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyPickerSection({
|
||||
children,
|
||||
disabled,
|
||||
isOpen,
|
||||
label,
|
||||
onOpen,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled: boolean
|
||||
isOpen: boolean
|
||||
label: string
|
||||
onOpen: () => void
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<PropertyPickerRow disabled={disabled} isOpen={isOpen} label={label} value={value} onPress={onOpen} />
|
||||
{isOpen ? children : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyPickerRow({
|
||||
disabled,
|
||||
isOpen,
|
||||
label,
|
||||
onPress,
|
||||
value,
|
||||
}: {
|
||||
disabled: boolean
|
||||
isOpen: boolean
|
||||
label: string
|
||||
onPress: () => void
|
||||
value: string
|
||||
}) {
|
||||
const Caret = isOpen ? CaretDown : CaretRight
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.propertyRow, disabled ? styles.propertyDisabled : null, pressed ? styles.pressed : null]}
|
||||
>
|
||||
<Text style={styles.propertyLabel}>{label}</Text>
|
||||
<Text numberOfLines={1} style={styles.propertyValue}>{value}</Text>
|
||||
<Caret size={16} color={colors.textSoft} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyIconChip({
|
||||
disabled,
|
||||
onSelect,
|
||||
option,
|
||||
value,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onSelect: (option: string) => void
|
||||
option: string
|
||||
value: string | undefined
|
||||
}) {
|
||||
const isSelected = isMobileNotePropertySelected({ current: value, option })
|
||||
|
||||
return (
|
||||
<SelectablePropertyChip
|
||||
disabled={disabled}
|
||||
isSelected={isSelected}
|
||||
onPress={() => onSelect(option)}
|
||||
style={styles.propertyIconChip}
|
||||
>
|
||||
<NamedIcon color={isSelected ? colors.primary : colors.textSoft} name={option as IconName} size={20} />
|
||||
</SelectablePropertyChip>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyChipOptions({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<View style={styles.propertyChipRow}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyComboOptions({
|
||||
disabled,
|
||||
onSelect,
|
||||
query,
|
||||
suggestions,
|
||||
value,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onSelect: (option: string) => void
|
||||
query: string
|
||||
suggestions: readonly string[]
|
||||
value: string
|
||||
}) {
|
||||
const options = filterMobilePropertyComboOptions({ options: suggestions, query })
|
||||
|
||||
return (
|
||||
<View style={styles.propertyComboBox}>
|
||||
{options.map((option) => {
|
||||
const isSelected = isMobileNotePropertySelected({ current: value, option })
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
key={option || 'none'}
|
||||
onPress={() => onSelect(option)}
|
||||
style={({ pressed }) => [
|
||||
styles.propertyComboOption,
|
||||
isSelected ? styles.propertyComboOptionSelected : null,
|
||||
disabled ? styles.propertyDisabled : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.propertyComboOptionText, isSelected ? styles.propertyComboOptionTextSelected : null]}>
|
||||
{option || 'None'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
{options.length === 0 ? <Text style={styles.propertyComboEmpty}>No matching types.</Text> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyTextChip({
|
||||
disabled,
|
||||
onSelect,
|
||||
option,
|
||||
value,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onSelect: (option: string) => void
|
||||
option: string
|
||||
value: readonly string[] | string | undefined
|
||||
}) {
|
||||
const isSelected = Array.isArray(value)
|
||||
? value.includes(option)
|
||||
: isMobileNotePropertySelected({ current: typeof value === 'string' ? value : undefined, option })
|
||||
|
||||
return (
|
||||
<SelectablePropertyChip
|
||||
disabled={disabled}
|
||||
isSelected={isSelected}
|
||||
onPress={() => onSelect(option)}
|
||||
style={styles.propertyChip}
|
||||
>
|
||||
<Text style={[styles.propertyChipText, isSelected ? styles.propertyChipTextSelected : null]}>
|
||||
{option || 'None'}
|
||||
</Text>
|
||||
</SelectablePropertyChip>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectablePropertyChip({
|
||||
children,
|
||||
disabled,
|
||||
isSelected,
|
||||
onPress,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled: boolean
|
||||
isSelected: boolean
|
||||
onPress: () => void
|
||||
style: StyleProp<ViewStyle>
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
style,
|
||||
isSelected ? styles.propertyChipSelected : null,
|
||||
disabled ? styles.propertyDisabled : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function commitTextValue({
|
||||
current,
|
||||
next,
|
||||
onCommit,
|
||||
onSettled,
|
||||
}: {
|
||||
current: string
|
||||
next: string
|
||||
onCommit: (value: string) => void
|
||||
onSettled?: (value: string) => void
|
||||
}) {
|
||||
const normalized = next.trim()
|
||||
onSettled?.(normalized)
|
||||
if (normalized !== current) {
|
||||
onCommit(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function formatMobilePropertyDate(date: Date) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
126
apps/mobile/src/MobileEditorAdapter.tsx
Normal file
126
apps/mobile/src/MobileEditorAdapter.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { KeyboardAvoidingView, Platform, View } from 'react-native'
|
||||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import { RichText, Toolbar, useEditorBridge } from '@10play/tentap-editor'
|
||||
import { MobileEditorWikilinkSuggestions } from './MobileEditorWikilinkSuggestions'
|
||||
import { parseEditorMessage, type MobileEditorWikilinkFrame } from './mobileEditorMessages'
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
|
||||
import {
|
||||
createMobileEditorDocument,
|
||||
createMobileEditorHtml,
|
||||
} from './mobileEditorDocument'
|
||||
import { resolveMobileRelationshipNote } from './mobileRelationshipRefs'
|
||||
import { mobileEditorBridgeExtensions } from './mobileWikilinkEditorBridge'
|
||||
import { mobileEditorCss, mobileEditorSetupScript } from './mobileEditorWebViewSetup'
|
||||
import { styles } from './styles'
|
||||
|
||||
export function MobileEditorAdapter({
|
||||
notes,
|
||||
note,
|
||||
onCreateNote,
|
||||
onDraftChange,
|
||||
onOpenNote,
|
||||
}: {
|
||||
notes: MobileNote[]
|
||||
note: MobileNote
|
||||
onCreateNote: () => void
|
||||
onDraftChange?: (draft: MobileEditorDraft) => void
|
||||
onOpenNote?: (noteId: string) => void
|
||||
}) {
|
||||
const document = useMemo(() => createMobileEditorDocument(note), [note])
|
||||
const initialContent = useMemo(() => createMobileEditorHtml(document), [document])
|
||||
const [wikilinkQuery, setWikilinkQuery] = useState<string | null>(null)
|
||||
const [wikilinkFrame, setWikilinkFrame] = useState<MobileEditorWikilinkFrame | null>(null)
|
||||
const draftTargetRef = useRef({ note, onDraftChange })
|
||||
useEffect(() => {
|
||||
draftTargetRef.current = { note, onDraftChange }
|
||||
}, [note, onDraftChange])
|
||||
const editor = useEditorBridge({
|
||||
avoidIosKeyboard: true,
|
||||
bridgeExtensions: mobileEditorBridgeExtensions,
|
||||
initialContent,
|
||||
onChange: () => {
|
||||
const draftTarget = draftTargetRef.current
|
||||
void editor.getHTML().then((editorHtml) => {
|
||||
draftTarget.onDraftChange?.(createMobileEditorDraft({ editorHtml, note: draftTarget.note }))
|
||||
})
|
||||
},
|
||||
})
|
||||
const handleMessage = (event: WebViewMessageEvent) => {
|
||||
const message = parseEditorMessage(event.nativeEvent.data)
|
||||
if (!message) return
|
||||
|
||||
if (message.type === 'shortcut' && message.command === 'fileNewNote') {
|
||||
onCreateNote()
|
||||
return
|
||||
}
|
||||
if (message.type === 'wikilinkQuery') {
|
||||
setWikilinkQuery(message.query)
|
||||
setWikilinkFrame(message.frame)
|
||||
return
|
||||
}
|
||||
if (message.type === 'listIndent') {
|
||||
handleListIndent({ direction: message.direction, editor })
|
||||
return
|
||||
}
|
||||
if (message.type !== 'openWikilink') return
|
||||
|
||||
const targetNote = resolveMobileRelationshipNote({ notes, target: message.target })
|
||||
if (targetNote) {
|
||||
onOpenNote?.(targetNote.id)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
applyMobileEditorWebViewSetup(editor)
|
||||
}, 250)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [editor, note.id])
|
||||
|
||||
return (
|
||||
<View style={styles.editorAdapterContent}>
|
||||
<View style={styles.tentapEditor}>
|
||||
<RichText key={note.id} editor={editor} onLoad={() => applyMobileEditorWebViewSetup(editor)} onMessage={handleMessage} />
|
||||
</View>
|
||||
<MobileEditorWikilinkSuggestions
|
||||
excludeNoteId={note.id}
|
||||
frame={wikilinkFrame}
|
||||
notes={notes}
|
||||
onSelectNote={(targetNote) => {
|
||||
editor.insertWikilink({ label: targetNote.title, target: targetNote.id })
|
||||
setWikilinkQuery(null)
|
||||
setWikilinkFrame(null)
|
||||
}}
|
||||
query={wikilinkQuery}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.tentapToolbar}
|
||||
>
|
||||
<Toolbar editor={editor} />
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function applyMobileEditorWebViewSetup(editor: ReturnType<typeof useEditorBridge>) {
|
||||
editor.injectCSS(mobileEditorCss, 'tolaria-mobile-editor')
|
||||
editor.injectJS(mobileEditorSetupScript)
|
||||
}
|
||||
|
||||
function handleListIndent({
|
||||
direction,
|
||||
editor,
|
||||
}: {
|
||||
direction: 'in' | 'out'
|
||||
editor: ReturnType<typeof useEditorBridge>
|
||||
}) {
|
||||
if (direction === 'in') {
|
||||
editor.sink()
|
||||
return
|
||||
}
|
||||
|
||||
editor.lift()
|
||||
}
|
||||
81
apps/mobile/src/MobileEditorBreadcrumb.tsx
Normal file
81
apps/mobile/src/MobileEditorBreadcrumb.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Archive, Code, PencilSimpleLine, Star, Tray } from 'phosphor-react-native'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import type { MobileNote } from './demoData'
|
||||
import type { MobileEditorSaveState } from './mobileEditorSaveState'
|
||||
import { styles } from './styles'
|
||||
import { colors } from './theme'
|
||||
|
||||
export function MobileEditorBreadcrumb({
|
||||
isRawMode,
|
||||
note,
|
||||
onToggleArchive,
|
||||
onToggleFavorite,
|
||||
onToggleRawMode,
|
||||
saveState,
|
||||
}: {
|
||||
isRawMode: boolean
|
||||
note: MobileNote
|
||||
onToggleArchive: () => void
|
||||
onToggleFavorite: () => void
|
||||
onToggleRawMode: () => void
|
||||
saveState: MobileEditorSaveState
|
||||
}) {
|
||||
const ArchiveIcon = note.archived ? Tray : Archive
|
||||
const RawIcon = isRawMode ? PencilSimpleLine : Code
|
||||
|
||||
return (
|
||||
<View style={styles.editorBreadcrumb}>
|
||||
<Text numberOfLines={1} style={styles.editorBreadcrumbText}>{note.type}</Text>
|
||||
<Text style={styles.editorBreadcrumbDivider}>/</Text>
|
||||
<Text numberOfLines={1} style={styles.editorBreadcrumbTitle}>{note.id}</Text>
|
||||
<Text style={[styles.editorSaveState, saveStateStyle(saveState)]}>{saveState.label}</Text>
|
||||
<BreadcrumbButton label={isRawMode ? 'Rich editor' : 'Raw editor'} onPress={onToggleRawMode}>
|
||||
<RawIcon color={isRawMode ? colors.primary : colors.textSoft} size={18} />
|
||||
</BreadcrumbButton>
|
||||
<BreadcrumbButton label={note.favorite ? 'Remove favorite' : 'Add favorite'} onPress={onToggleFavorite}>
|
||||
<Star color={note.favorite ? colors.primary : colors.textSoft} size={18} weight={note.favorite ? 'fill' : 'regular'} />
|
||||
</BreadcrumbButton>
|
||||
<BreadcrumbButton label={note.archived ? 'Move to inbox' : 'Archive'} onPress={onToggleArchive}>
|
||||
<ArchiveIcon color={colors.textSoft} size={18} />
|
||||
</BreadcrumbButton>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function saveStateStyle(saveState: MobileEditorSaveState) {
|
||||
switch (saveState.state) {
|
||||
case 'blocked':
|
||||
return styles.editorSaveState_blocked
|
||||
case 'failed':
|
||||
return styles.editorSaveState_failed
|
||||
case 'queued':
|
||||
return styles.editorSaveState_queued
|
||||
case 'saved':
|
||||
return styles.editorSaveState_saved
|
||||
case 'saving':
|
||||
return styles.editorSaveState_saving
|
||||
default:
|
||||
return styles.editorSaveState_idle
|
||||
}
|
||||
}
|
||||
|
||||
function BreadcrumbButton({
|
||||
children,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
children: ReactNode
|
||||
label: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.editorBreadcrumbButton, pressed ? styles.pressed : null]}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
57
apps/mobile/src/MobileEditorWikilinkSuggestions.tsx
Normal file
57
apps/mobile/src/MobileEditorWikilinkSuggestions.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import type { MobileEditorWikilinkFrame } from './mobileEditorMessages'
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
|
||||
import { styles } from './styles'
|
||||
|
||||
export function MobileEditorWikilinkSuggestions({
|
||||
frame,
|
||||
excludeNoteId,
|
||||
notes,
|
||||
onSelectNote,
|
||||
query,
|
||||
}: {
|
||||
frame: MobileEditorWikilinkFrame | null
|
||||
excludeNoteId: string
|
||||
notes: MobileNote[]
|
||||
onSelectNote: (note: MobileNote) => void
|
||||
query: string | null
|
||||
}) {
|
||||
const suggestions = useMemo(() => {
|
||||
return query === null
|
||||
? []
|
||||
: mobileNoteSuggestions({ excludeNoteId, notes, query })
|
||||
}, [excludeNoteId, notes, query])
|
||||
|
||||
if (query === null || suggestions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.rawEditorSuggestionMenu, suggestionMenuPosition(frame)]}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Pressable
|
||||
key={suggestion.id}
|
||||
onPress={() => onSelectNote(suggestion)}
|
||||
style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]}
|
||||
>
|
||||
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
|
||||
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function suggestionMenuPosition(frame: MobileEditorWikilinkFrame | null) {
|
||||
if (!frame) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
bottom: undefined,
|
||||
left: Math.max(16, frame.left),
|
||||
top: Math.max(16, frame.bottom + 8),
|
||||
}
|
||||
}
|
||||
53
apps/mobile/src/MobileGitSyncStatusCard.tsx
Normal file
53
apps/mobile/src/MobileGitSyncStatusCard.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { GitBranch, WarningCircle } from 'phosphor-react-native'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { mobileGitSyncStatusView, type MobileGitSyncStatusTone } from './mobileGitSyncStatus'
|
||||
import type { MobileGitSyncPlan } from './mobileGitSyncPlan'
|
||||
import { styles } from './styles'
|
||||
import { colors } from './theme'
|
||||
|
||||
export function MobileGitSyncStatusCard({
|
||||
onPrimaryAction,
|
||||
plan,
|
||||
}: {
|
||||
onPrimaryAction?: () => void
|
||||
plan: MobileGitSyncPlan
|
||||
}) {
|
||||
const status = mobileGitSyncStatusView(plan)
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
const Icon = status.tone === 'warning' || status.tone === 'attention' ? WarningCircle : GitBranch
|
||||
|
||||
return (
|
||||
<View style={[styles.gitSyncStatus, gitSyncToneStyle(status.tone)]}>
|
||||
<Icon size={18} color={colors.textSoft} />
|
||||
<View style={styles.gitSyncStatusCopy}>
|
||||
<Text style={styles.gitSyncStatusLabel}>{status.label}</Text>
|
||||
<Text style={styles.gitSyncStatusDetail}>{status.detail}</Text>
|
||||
</View>
|
||||
{status.actionLabel ? (
|
||||
<Pressable
|
||||
accessibilityLabel={status.actionLabel}
|
||||
onPress={onPrimaryAction}
|
||||
style={({ pressed }) => pressed ? styles.pressed : null}
|
||||
>
|
||||
<Text style={styles.gitSyncStatusAction}>{status.actionLabel}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function gitSyncToneStyle(tone: MobileGitSyncStatusTone) {
|
||||
switch (tone) {
|
||||
case 'attention':
|
||||
return styles.gitSyncStatus_attention
|
||||
case 'neutral':
|
||||
return styles.gitSyncStatus_neutral
|
||||
case 'positive':
|
||||
return styles.gitSyncStatus_positive
|
||||
case 'warning':
|
||||
return styles.gitSyncStatus_warning
|
||||
}
|
||||
}
|
||||
477
apps/mobile/src/MobilePropertiesPanel.tsx
Normal file
477
apps/mobile/src/MobilePropertiesPanel.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import { CaretLeft, Plus, X } from 'phosphor-react-native'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
|
||||
import type { MobileNote } from './demoData'
|
||||
import { mobileDerivedRelationshipGroups } from './mobileDerivedRelationships'
|
||||
import { MobileEditablePropertyPickers } from './MobileEditablePropertyPickers'
|
||||
import { MobileRelationshipNotePicker } from './MobileRelationshipNotePicker'
|
||||
import type { MobileNotePropertyPatch } from './mobileNoteProperties'
|
||||
import { nextMobilePropertyPicker, type MobilePropertyPickerKey } from './mobilePropertyPicker'
|
||||
import {
|
||||
canonicalMobileRelationshipRef,
|
||||
filterMobileRelationshipRef,
|
||||
hasMobileRelationshipRef,
|
||||
mobileRelationshipDisplayLabel,
|
||||
mobileWikilinkForNote,
|
||||
resolveMobileRelationshipNote,
|
||||
uniqueMobileRelationshipRefs,
|
||||
} from './mobileRelationshipRefs'
|
||||
import { mobileRelationshipAppearance } from './mobileTypeAppearance'
|
||||
import { styles } from './styles'
|
||||
import { colors } from './theme'
|
||||
|
||||
export function MobilePropertiesPanel({
|
||||
failed = false,
|
||||
isSaving = false,
|
||||
notes = [],
|
||||
note,
|
||||
onChangeProperties,
|
||||
onClose,
|
||||
onOpenNote,
|
||||
}: {
|
||||
failed?: boolean
|
||||
isSaving?: boolean
|
||||
notes?: MobileNote[]
|
||||
note: MobileNote
|
||||
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
|
||||
onClose?: () => void
|
||||
onOpenNote?: (noteId: string) => void
|
||||
}) {
|
||||
const [openPicker, setOpenPicker] = useState<MobilePropertyPickerKey | null>(null)
|
||||
const selectPicker = (selected: MobilePropertyPickerKey) => {
|
||||
setOpenPicker((current) => nextMobilePropertyPicker({ current, selected }))
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.properties}>
|
||||
<PanelToolbar onClose={onClose} />
|
||||
<ScrollView contentContainerStyle={styles.propertiesContent}>
|
||||
{failed ? <Text style={styles.propertyError}>Could not save property.</Text> : null}
|
||||
<PropertySection title="System">
|
||||
<MobileEditablePropertyPickers
|
||||
disabled={isSaving}
|
||||
note={note}
|
||||
openPicker={openPicker}
|
||||
onChangeProperties={onChangeProperties}
|
||||
onSelectPicker={selectPicker}
|
||||
/>
|
||||
</PropertySection>
|
||||
<PropertySection title="Relationships">
|
||||
<EditableRelationships note={note} notes={notes} onChangeProperties={onChangeProperties} onOpenNote={onOpenNote} />
|
||||
<DerivedRelationships note={note} notes={notes} onOpenNote={onOpenNote} />
|
||||
</PropertySection>
|
||||
<CustomProperties note={note} onChangeProperties={onChangeProperties} />
|
||||
<PropertySection title="Info">
|
||||
<BacklinkGroup backlinks={note.backlinks} onOpenNote={onOpenNote} />
|
||||
<PropertyRow label="Words" value={String(note.words)} />
|
||||
<PropertyRow label="Modified" value={note.modified} />
|
||||
</PropertySection>
|
||||
<PropertySection title="History">
|
||||
<Text style={styles.historyItem}>eb373865c - Updated 1 note</Text>
|
||||
<Text style={styles.historyItem}>5e853fdfe - Updated 1 note</Text>
|
||||
</PropertySection>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertySection({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.propertySection}>
|
||||
<Text style={styles.propertyGroupTitle}>{title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function EditableRelationships({
|
||||
note,
|
||||
notes,
|
||||
onChangeProperties,
|
||||
onOpenNote,
|
||||
}: {
|
||||
note: MobileNote
|
||||
notes: MobileNote[]
|
||||
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
|
||||
onOpenNote?: (noteId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<RelationshipGroup
|
||||
label="Belongs to"
|
||||
notes={notes}
|
||||
targets={note.belongsTo}
|
||||
onChangeTargets={(belongsTo) => onChangeProperties?.({ belongsTo })}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
<RelationshipGroup
|
||||
label="Related to"
|
||||
notes={notes}
|
||||
targets={[...note.relatedTo, ...note.outgoingLinks]}
|
||||
writableTargets={note.relatedTo}
|
||||
onChangeTargets={(relatedTo) => onChangeProperties?.({ relatedTo })}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
<RelationshipGroup
|
||||
label="Has"
|
||||
notes={notes}
|
||||
targets={note.has}
|
||||
onChangeTargets={(has) => onChangeProperties?.({ has })}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
{Object.entries(note.relationships).map(([key, targets]) => (
|
||||
<RelationshipGroup
|
||||
key={key}
|
||||
label={formatRelationshipLabel(key)}
|
||||
notes={notes}
|
||||
onDeleteGroup={() => onChangeProperties?.({
|
||||
relationships: removeRelationshipKey({ key, relationships: note.relationships }),
|
||||
removedRelationshipKeys: [key],
|
||||
})}
|
||||
targets={targets}
|
||||
onChangeTargets={(nextTargets) => onChangeProperties?.({
|
||||
relationships: { ...note.relationships, [key]: nextTargets },
|
||||
removedRelationshipKeys: nextTargets.length === 0 ? [key] : undefined,
|
||||
})}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
<AddRelationshipGroup
|
||||
note={note}
|
||||
notes={notes}
|
||||
onAdd={(key, target) => onChangeProperties?.({ relationships: { ...note.relationships, [key]: [target] } })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DerivedRelationships({
|
||||
note,
|
||||
notes,
|
||||
onOpenNote,
|
||||
}: {
|
||||
note: MobileNote
|
||||
notes: MobileNote[]
|
||||
onOpenNote?: (noteId: string) => void
|
||||
}) {
|
||||
const groups = mobileDerivedRelationshipGroups({ note, notes })
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<RelationshipGroup
|
||||
key={group.label}
|
||||
label={group.label}
|
||||
notes={notes}
|
||||
targets={group.targets}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipGroup({
|
||||
label,
|
||||
notes,
|
||||
onChangeTargets,
|
||||
onDeleteGroup,
|
||||
onOpenNote,
|
||||
targets,
|
||||
writableTargets = targets,
|
||||
}: {
|
||||
label: string
|
||||
notes: MobileNote[]
|
||||
onChangeTargets?: (targets: string[]) => void
|
||||
onDeleteGroup?: () => void
|
||||
onOpenNote?: (noteId: string) => void
|
||||
targets: string[]
|
||||
writableTargets?: string[]
|
||||
}) {
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const uniqueTargets = uniqueMobileRelationshipRefs(targets)
|
||||
const addTarget = (selectedNote: MobileNote) => {
|
||||
const target = canonicalMobileRelationshipRef({ notes, value: mobileWikilinkForNote(selectedNote) })
|
||||
if (!target) return
|
||||
|
||||
onChangeTargets?.(uniqueMobileRelationshipRefs([...writableTargets, target]))
|
||||
setIsAdding(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.relationshipGroup}>
|
||||
<RelationshipHeader
|
||||
canAdd={Boolean(onChangeTargets)}
|
||||
canDelete={Boolean(onDeleteGroup)}
|
||||
label={label}
|
||||
onAdd={() => setIsAdding(true)}
|
||||
onDelete={onDeleteGroup}
|
||||
/>
|
||||
<View style={styles.relationshipChipRow}>
|
||||
{uniqueTargets.length === 0 ? <Text style={styles.relationshipEmpty}>None</Text> : null}
|
||||
{uniqueTargets.map((target) => (
|
||||
<RelationshipChip
|
||||
key={target}
|
||||
note={resolveMobileRelationshipNote({ notes, target })}
|
||||
onRemove={hasMobileRelationshipRef({ target, values: writableTargets }) && onChangeTargets
|
||||
? () => onChangeTargets(filterMobileRelationshipRef({ target, values: writableTargets }))
|
||||
: undefined}
|
||||
target={target}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<MobileRelationshipNotePicker
|
||||
notes={notes}
|
||||
title={`Add ${label}`}
|
||||
visible={isAdding}
|
||||
onClose={() => setIsAdding(false)}
|
||||
onSelectNote={addTarget}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipHeader({
|
||||
canAdd,
|
||||
canDelete,
|
||||
label,
|
||||
onAdd,
|
||||
onDelete,
|
||||
}: {
|
||||
canAdd: boolean
|
||||
canDelete: boolean
|
||||
label: string
|
||||
onAdd: () => void
|
||||
onDelete?: () => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.relationshipHeader}>
|
||||
<Text style={styles.propertyGroupTitle}>{label}</Text>
|
||||
<View style={styles.relationshipHeaderActions}>
|
||||
{canDelete ? (
|
||||
<Pressable onPress={onDelete} style={({ pressed }) => [styles.relationshipAddButton, pressed ? styles.pressed : null]}>
|
||||
<X color={colors.textSoft} size={14} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
{canAdd ? (
|
||||
<Pressable onPress={onAdd} style={({ pressed }) => [styles.relationshipAddButton, pressed ? styles.pressed : null]}>
|
||||
<Plus color={colors.textSoft} size={14} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRelationshipGroup({
|
||||
note,
|
||||
notes,
|
||||
onAdd,
|
||||
}: {
|
||||
note: MobileNote
|
||||
notes: MobileNote[]
|
||||
onAdd: (key: string, target: string) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [pendingKey, setPendingKey] = useState<string | null>(null)
|
||||
const openTargetPicker = () => {
|
||||
const key = relationshipKeyFromLabel(name)
|
||||
if (key && !note.relationships[key]) {
|
||||
setPendingKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.relationshipGroup}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setName}
|
||||
onSubmitEditing={openTargetPicker}
|
||||
placeholder="+ Add relationship"
|
||||
placeholderTextColor={colors.mutedText}
|
||||
style={styles.relationshipInput}
|
||||
value={name}
|
||||
/>
|
||||
<MobileRelationshipNotePicker
|
||||
notes={notes}
|
||||
title={`Add ${formatRelationshipLabel(pendingKey ?? 'relationship')}`}
|
||||
visible={Boolean(pendingKey)}
|
||||
onClose={() => setPendingKey(null)}
|
||||
onSelectNote={(targetNote) => {
|
||||
if (!pendingKey) return
|
||||
|
||||
onAdd(pendingKey, mobileWikilinkForNote(targetNote))
|
||||
setName('')
|
||||
setPendingKey(null)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomProperties({
|
||||
note,
|
||||
onChangeProperties,
|
||||
}: {
|
||||
note: MobileNote
|
||||
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
|
||||
}) {
|
||||
const entries = Object.entries(note.customProperties)
|
||||
|
||||
return (
|
||||
<PropertySection title="Custom properties">
|
||||
{entries.length === 0 ? <Text style={styles.relationshipEmpty}>None</Text> : null}
|
||||
{entries.map(([key, value]) => (
|
||||
<PropertyRow
|
||||
key={key}
|
||||
label={formatRelationshipLabel(key)}
|
||||
value={value}
|
||||
onDelete={() => onChangeProperties?.({
|
||||
customProperties: removeCustomPropertyKey({ customProperties: note.customProperties, key }),
|
||||
removedCustomPropertyKeys: [key],
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</PropertySection>
|
||||
)
|
||||
}
|
||||
|
||||
function BacklinkGroup({
|
||||
backlinks,
|
||||
onOpenNote,
|
||||
}: {
|
||||
backlinks: MobileNote['backlinks']
|
||||
onOpenNote?: (noteId: string) => void
|
||||
}) {
|
||||
if (backlinks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.relationshipGroup}>
|
||||
<Text style={styles.propertyGroupTitle}>Linked from</Text>
|
||||
<View style={styles.relationshipChipRow}>
|
||||
{backlinks.map((backlink) => (
|
||||
<RelationshipChip
|
||||
key={backlink.id}
|
||||
note={backlink}
|
||||
target={backlink.title}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipChip({
|
||||
note,
|
||||
onOpenNote,
|
||||
onRemove,
|
||||
target,
|
||||
}: {
|
||||
note?: { id: string; title: string; type?: string }
|
||||
onOpenNote?: (noteId: string) => void
|
||||
onRemove?: () => void
|
||||
target: string
|
||||
}) {
|
||||
const appearance = mobileRelationshipAppearance(note?.type)
|
||||
const chip = <Text style={styles.relationshipChipText}>{note?.title ?? mobileRelationshipDisplayLabel(target)}</Text>
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{chip}
|
||||
{onRemove ? (
|
||||
<Pressable onPress={onRemove} style={styles.relationshipRemoveButton}>
|
||||
<X color={appearance.color} size={12} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
return note && onOpenNote ? (
|
||||
<Pressable
|
||||
onPress={() => onOpenNote(note.id)}
|
||||
style={({ pressed }) => [
|
||||
styles.relationshipChip,
|
||||
{ backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor },
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
{content}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={[styles.relationshipChip, { backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor }]}>{content}</View>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelationshipLabel(key: string) {
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function relationshipKeyFromLabel(label: string) {
|
||||
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
}
|
||||
|
||||
function PanelToolbar({ onClose }: { onClose?: () => void }) {
|
||||
return (
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.propertiesTitle}>Properties</Text>
|
||||
<View style={styles.toolbarSpacer} />
|
||||
{onClose ? (
|
||||
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
|
||||
<CaretLeft size={23} color={colors.textSoft} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyRow({
|
||||
label,
|
||||
onDelete,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
onDelete?: () => void
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.propertyRow}>
|
||||
<Text style={styles.propertyLabel}>{label}</Text>
|
||||
<Text style={styles.propertyValue}>{value}</Text>
|
||||
{onDelete ? (
|
||||
<Pressable onPress={onDelete} style={styles.relationshipRemoveButton}>
|
||||
<X color={colors.textSoft} size={14} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function removeRelationshipKey({
|
||||
key,
|
||||
relationships,
|
||||
}: {
|
||||
key: string
|
||||
relationships: Record<string, string[]>
|
||||
}) {
|
||||
return Object.fromEntries(Object.entries(relationships).filter(([relationshipKey]) => relationshipKey !== key))
|
||||
}
|
||||
|
||||
function removeCustomPropertyKey({
|
||||
customProperties,
|
||||
key,
|
||||
}: {
|
||||
customProperties: Record<string, string>
|
||||
key: string
|
||||
}) {
|
||||
return Object.fromEntries(Object.entries(customProperties).filter(([propertyKey]) => propertyKey !== key))
|
||||
}
|
||||
62
apps/mobile/src/MobileRawEditor.tsx
Normal file
62
apps/mobile/src/MobileRawEditor.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Pressable, Text, TextInput, View } from 'react-native'
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
import { activeMobileWikilinkQuery, insertMobileWikilink, mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
|
||||
import { styles } from './styles'
|
||||
|
||||
export function MobileRawEditor({
|
||||
notes,
|
||||
note,
|
||||
onRawMarkdownChange,
|
||||
}: {
|
||||
notes: MobileNote[]
|
||||
note: MobileNote
|
||||
onRawMarkdownChange: (markdown: string) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(note.content)
|
||||
const [cursor, setCursor] = useState(note.content.length)
|
||||
const activeQuery = useMemo(() => activeMobileWikilinkQuery({ cursor, markdown: draft }), [cursor, draft])
|
||||
const suggestions = useMemo(
|
||||
() => activeQuery ? mobileNoteSuggestions({ excludeNoteId: note.id, notes, query: activeQuery.query }) : [],
|
||||
[activeQuery, note.id, notes],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.rawEditorContent}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
multiline
|
||||
onChangeText={(markdown) => {
|
||||
setDraft(markdown)
|
||||
onRawMarkdownChange(markdown)
|
||||
}}
|
||||
onSelectionChange={(event) => setCursor(event.nativeEvent.selection.start)}
|
||||
scrollEnabled
|
||||
spellCheck={false}
|
||||
style={styles.rawEditorInput}
|
||||
textAlignVertical="top"
|
||||
value={draft}
|
||||
/>
|
||||
{suggestions.length > 0 && activeQuery ? (
|
||||
<View style={styles.rawEditorSuggestionMenu}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Pressable
|
||||
key={suggestion.id}
|
||||
onPress={() => {
|
||||
const nextDraft = insertMobileWikilink({ markdown: draft, note: suggestion, query: activeQuery })
|
||||
setDraft(nextDraft)
|
||||
setCursor(activeQuery.start + suggestion.id.length + suggestion.title.length + 5)
|
||||
onRawMarkdownChange(nextDraft)
|
||||
}}
|
||||
style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]}
|
||||
>
|
||||
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
|
||||
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
73
apps/mobile/src/MobileRelationshipNotePicker.tsx
Normal file
73
apps/mobile/src/MobileRelationshipNotePicker.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { X } from 'phosphor-react-native'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Modal, Pressable, Text, TextInput, View, type GestureResponderEvent } from 'react-native'
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
|
||||
import { styles } from './styles'
|
||||
import { colors } from './theme'
|
||||
|
||||
export function MobileRelationshipNotePicker({
|
||||
notes,
|
||||
onClose,
|
||||
onSelectNote,
|
||||
title,
|
||||
visible,
|
||||
}: {
|
||||
notes: MobileNote[]
|
||||
onClose: () => void
|
||||
onSelectNote: (note: MobileNote) => void
|
||||
title: string
|
||||
visible: boolean
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const suggestions = useMemo(
|
||||
() => query.trim() ? mobileNoteSuggestions({ notes, query }) : [],
|
||||
[notes, query],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" transparent visible={visible} onRequestClose={onClose}>
|
||||
<Pressable style={styles.relationshipPickerOverlay} onPress={onClose}>
|
||||
<Pressable style={styles.relationshipPickerPanel} onPress={stopPressPropagation}>
|
||||
<View style={styles.relationshipPickerHeader}>
|
||||
<Text style={styles.relationshipPickerTitle}>{title}</Text>
|
||||
<Pressable onPress={onClose} style={styles.relationshipPickerClose}>
|
||||
<X color={colors.textSoft} size={18} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search notes"
|
||||
placeholderTextColor={colors.mutedText}
|
||||
style={styles.relationshipPickerInput}
|
||||
value={query}
|
||||
/>
|
||||
<View style={styles.relationshipPickerResults}>
|
||||
{query.trim() ? null : <Text style={styles.relationshipPickerEmpty}>Type to find a note.</Text>}
|
||||
{query.trim() && suggestions.length === 0 ? <Text style={styles.relationshipPickerEmpty}>No matching notes.</Text> : null}
|
||||
{suggestions.map((suggestion) => (
|
||||
<Pressable
|
||||
key={suggestion.id}
|
||||
onPress={() => {
|
||||
onSelectNote(suggestion)
|
||||
setQuery('')
|
||||
}}
|
||||
style={({ pressed }) => [styles.relationshipPickerResult, pressed ? styles.pressed : null]}
|
||||
>
|
||||
<Text numberOfLines={1} style={styles.relationshipPickerResultTitle}>{suggestion.title}</Text>
|
||||
<Text numberOfLines={1} style={styles.relationshipPickerResultMeta}>{suggestion.type}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function stopPressPropagation(event: GestureResponderEvent) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
45
apps/mobile/src/MobileVaultManagementCard.tsx
Normal file
45
apps/mobile/src/MobileVaultManagementCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { GitBranch, HardDrive, SlidersHorizontal } from 'phosphor-react-native'
|
||||
import { styles } from './styles'
|
||||
import { colors } from './theme'
|
||||
import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
import { createMobileVaultManagementSummary } from './mobileVaultManagementSummary'
|
||||
|
||||
export function MobileVaultManagementCard({
|
||||
onOpenRemoteSetup,
|
||||
vault,
|
||||
}: {
|
||||
onOpenRemoteSetup: () => void
|
||||
vault: MobileVaultMetadata
|
||||
}) {
|
||||
const summary = createMobileVaultManagementSummary(vault)
|
||||
|
||||
return (
|
||||
<View style={styles.vaultManagementCard}>
|
||||
<View style={styles.vaultManagementHeader}>
|
||||
<View style={styles.vaultManagementIcon}>
|
||||
<HardDrive size={18} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.vaultManagementTitleGroup}>
|
||||
<Text numberOfLines={1} style={styles.vaultManagementTitle}>{summary.name}</Text>
|
||||
<Text style={styles.vaultManagementDetail}>{summary.storageLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.vaultManagementRemoteRow}>
|
||||
<GitBranch size={17} color={colors.iconMuted} />
|
||||
<View style={styles.vaultManagementRemoteText}>
|
||||
<Text style={styles.vaultManagementRemoteLabel}>{summary.remoteLabel}</Text>
|
||||
<Text numberOfLines={1} style={styles.vaultManagementDetail}>{summary.remoteDetail}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityLabel="Configure Git remote"
|
||||
onPress={onOpenRemoteSetup}
|
||||
style={({ pressed }) => [styles.vaultManagementAction, pressed ? styles.pressed : null]}
|
||||
>
|
||||
<SlidersHorizontal size={17} color={colors.primary} />
|
||||
<Text style={styles.vaultManagementActionText}>{summary.actionLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
73
apps/mobile/src/MobileVaultRemotePrompt.tsx
Normal file
73
apps/mobile/src/MobileVaultRemotePrompt.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { styles } from './styles'
|
||||
|
||||
export function MobileVaultRemotePrompt({
|
||||
failed,
|
||||
hasGitHubOAuthClientId,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onChangeRemoteUrl,
|
||||
onSubmit,
|
||||
remoteUrl,
|
||||
}: {
|
||||
failed: boolean
|
||||
hasGitHubOAuthClientId: boolean
|
||||
isSaving: boolean
|
||||
onCancel: () => void
|
||||
onChangeRemoteUrl: (remoteUrl: string) => void
|
||||
onSubmit: () => void
|
||||
remoteUrl: string
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.remotePrompt}>
|
||||
<Text style={styles.remotePromptTitle}>Git remote</Text>
|
||||
<TextInput
|
||||
accessibilityLabel="Git remote URL"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSaving}
|
||||
onChangeText={onChangeRemoteUrl}
|
||||
onSubmitEditing={onSubmit}
|
||||
placeholder="https://github.com/owner/repo.git"
|
||||
returnKeyType="done"
|
||||
style={styles.remotePromptInput}
|
||||
value={remoteUrl}
|
||||
/>
|
||||
{!hasGitHubOAuthClientId ? (
|
||||
<Text style={styles.remotePromptError}>GitHub login needs EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID</Text>
|
||||
) : null}
|
||||
{failed ? <Text style={styles.remotePromptError}>Enter a valid Git remote URL</Text> : null}
|
||||
<View style={styles.remotePromptActions}>
|
||||
<PromptButton label="Cancel" onPress={onCancel} />
|
||||
<PromptButton label={isSaving ? 'Saving' : 'Save'} onPress={onSubmit} disabled={isSaving} primary />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptButton({
|
||||
disabled,
|
||||
label,
|
||||
onPress,
|
||||
primary,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onPress: () => void
|
||||
primary?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.remotePromptAction,
|
||||
primary ? styles.remotePromptActionPrimary : null,
|
||||
disabled ? styles.remotePromptActionDisabled : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.remotePromptActionText, primary ? styles.remotePromptActionTextPrimary : null]}>{label}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
36
apps/mobile/src/NamedIcon.tsx
Normal file
36
apps/mobile/src/NamedIcon.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Archive,
|
||||
Books,
|
||||
Drop,
|
||||
FileText,
|
||||
Flag,
|
||||
GitBranch,
|
||||
PenNib,
|
||||
Robot,
|
||||
Star,
|
||||
Sun,
|
||||
Tray,
|
||||
Wrench,
|
||||
} from 'phosphor-react-native'
|
||||
|
||||
export type IconName = 'archive' | 'books' | 'drop' | 'file-text' | 'flag' | 'git-branch' | 'pen-nib' | 'robot' | 'star' | 'sun' | 'tray' | 'wrench'
|
||||
|
||||
const iconByName = {
|
||||
archive: Archive,
|
||||
books: Books,
|
||||
drop: Drop,
|
||||
'file-text': FileText,
|
||||
flag: Flag,
|
||||
'git-branch': GitBranch,
|
||||
'pen-nib': PenNib,
|
||||
robot: Robot,
|
||||
star: Star,
|
||||
sun: Sun,
|
||||
tray: Tray,
|
||||
wrench: Wrench,
|
||||
} as const
|
||||
|
||||
export function NamedIcon({ color, name, size }: { color: string; name: IconName; size: number }) {
|
||||
const Icon = iconByName[name]
|
||||
return <Icon color={color} size={size} weight="regular" />
|
||||
}
|
||||
37
apps/mobile/src/SwipeSurface.tsx
Normal file
37
apps/mobile/src/SwipeSurface.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { View } from 'react-native'
|
||||
import {
|
||||
PanGestureHandler,
|
||||
State,
|
||||
type PanGestureHandlerStateChangeEvent,
|
||||
} from 'react-native-gesture-handler'
|
||||
import { compactSwipeEvent, detectHorizontalSwipe } from './compactGestures'
|
||||
import type { CompactNavigationEvent, CompactPanel } from './compactNavigation'
|
||||
import { styles } from './styles'
|
||||
|
||||
export function SwipeSurface({
|
||||
children,
|
||||
panel,
|
||||
onNavigate,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
panel: CompactPanel
|
||||
onNavigate: (event: CompactNavigationEvent) => void
|
||||
}) {
|
||||
const handleStateChange = (event: PanGestureHandlerStateChangeEvent) => {
|
||||
if (event.nativeEvent.state !== State.END) {
|
||||
return
|
||||
}
|
||||
|
||||
const direction = detectHorizontalSwipe(event.nativeEvent)
|
||||
const navigationEvent = direction ? compactSwipeEvent(panel, direction) : null
|
||||
if (navigationEvent) {
|
||||
onNavigate(navigationEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PanGestureHandler activeOffsetX={[-18, 18]} failOffsetY={[-24, 24]} onHandlerStateChange={handleStateChange}>
|
||||
<View style={styles.swipeSurface}>{children}</View>
|
||||
</PanGestureHandler>
|
||||
)
|
||||
}
|
||||
25
apps/mobile/src/compactGestures.test.ts
Normal file
25
apps/mobile/src/compactGestures.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { compactSwipeEvent, detectHorizontalSwipe } from './compactGestures'
|
||||
|
||||
describe('compact mobile gestures', () => {
|
||||
it('ignores short slow horizontal drags', () => {
|
||||
expect(detectHorizontalSwipe({ translationX: 24, velocityX: 90 })).toBeNull()
|
||||
})
|
||||
|
||||
it('detects committed left and right swipes', () => {
|
||||
expect(detectHorizontalSwipe({ translationX: -72, velocityX: -120 })).toBe('left')
|
||||
expect(detectHorizontalSwipe({ translationX: 20, velocityX: 520 })).toBe('right')
|
||||
})
|
||||
|
||||
it('maps the requested Bear-style compact panel swipes', () => {
|
||||
expect(compactSwipeEvent('list', 'left')).toEqual({ type: 'openSidebar' })
|
||||
expect(compactSwipeEvent('sidebar', 'right')).toEqual({ type: 'closeSidebar' })
|
||||
expect(compactSwipeEvent('note', 'right')).toEqual({ type: 'openProperties' })
|
||||
expect(compactSwipeEvent('properties', 'left')).toEqual({ type: 'closeProperties' })
|
||||
})
|
||||
|
||||
it('does not invent transitions for unsupported panel directions', () => {
|
||||
expect(compactSwipeEvent('list', 'right')).toBeNull()
|
||||
expect(compactSwipeEvent('note', 'left')).toBeNull()
|
||||
})
|
||||
})
|
||||
36
apps/mobile/src/compactGestures.ts
Normal file
36
apps/mobile/src/compactGestures.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { CompactNavigationEvent, CompactPanel } from './compactNavigation'
|
||||
|
||||
export type SwipeDirection = 'left' | 'right'
|
||||
|
||||
export type SwipeSample = {
|
||||
translationX: number
|
||||
velocityX: number
|
||||
}
|
||||
|
||||
const MIN_TRANSLATION = 56
|
||||
const MIN_VELOCITY = 420
|
||||
const compactGestureEvents: Partial<Record<`${CompactPanel}:${SwipeDirection}`, CompactNavigationEvent>> = {
|
||||
'list:left': { type: 'openSidebar' },
|
||||
'sidebar:right': { type: 'closeSidebar' },
|
||||
'note:right': { type: 'openProperties' },
|
||||
'properties:left': { type: 'closeProperties' },
|
||||
}
|
||||
|
||||
export function detectHorizontalSwipe(sample: SwipeSample): SwipeDirection | null {
|
||||
if (!isCommittedSwipe(sample)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return sample.translationX < 0 ? 'left' : 'right'
|
||||
}
|
||||
|
||||
export function compactSwipeEvent(
|
||||
panel: CompactPanel,
|
||||
direction: SwipeDirection,
|
||||
): CompactNavigationEvent | null {
|
||||
return compactGestureEvents[`${panel}:${direction}`] ?? null
|
||||
}
|
||||
|
||||
function isCommittedSwipe(sample: SwipeSample) {
|
||||
return Math.abs(sample.translationX) >= MIN_TRANSLATION || Math.abs(sample.velocityX) >= MIN_VELOCITY
|
||||
}
|
||||
60
apps/mobile/src/compactNavigation.test.ts
Normal file
60
apps/mobile/src/compactNavigation.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createCompactNavigationState,
|
||||
transitionCompactNavigation,
|
||||
type CompactNavigationState,
|
||||
} from './compactNavigation'
|
||||
|
||||
describe('compact mobile navigation', () => {
|
||||
it('starts on the note list with the first note selected', () => {
|
||||
expect(createCompactNavigationState('workflow')).toEqual({
|
||||
panel: 'list',
|
||||
selectedNoteId: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a selected note from the list', () => {
|
||||
const next = transitionCompactNavigation(createCompactNavigationState('workflow'), {
|
||||
type: 'selectNote',
|
||||
noteId: 'release',
|
||||
})
|
||||
|
||||
expect(next).toEqual({
|
||||
panel: 'note',
|
||||
selectedNoteId: 'release',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns from sidebar and note surfaces to the list', () => {
|
||||
expect(panelAfter({ type: 'openSidebar' })).toBe('sidebar')
|
||||
expect(panelAfter({ type: 'closeSidebar' }, 'sidebar')).toBe('list')
|
||||
expect(panelAfter({ type: 'backToList' }, 'note')).toBe('list')
|
||||
})
|
||||
|
||||
it('opens and closes note properties without changing the selected note', () => {
|
||||
const noteState: CompactNavigationState = {
|
||||
panel: 'note',
|
||||
selectedNoteId: 'workflow',
|
||||
}
|
||||
|
||||
const propertiesState = transitionCompactNavigation(noteState, { type: 'openProperties' })
|
||||
const closedState = transitionCompactNavigation(propertiesState, { type: 'closeProperties' })
|
||||
|
||||
expect(propertiesState).toEqual({
|
||||
panel: 'properties',
|
||||
selectedNoteId: 'workflow',
|
||||
})
|
||||
expect(closedState).toEqual({
|
||||
panel: 'note',
|
||||
selectedNoteId: 'workflow',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function panelAfter(
|
||||
event: Parameters<typeof transitionCompactNavigation>[1],
|
||||
panel: CompactNavigationState['panel'] = 'list',
|
||||
) {
|
||||
const state = transitionCompactNavigation({ panel, selectedNoteId: 'workflow' }, event)
|
||||
return state.panel
|
||||
}
|
||||
55
apps/mobile/src/compactNavigation.ts
Normal file
55
apps/mobile/src/compactNavigation.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type CompactPanel = 'sidebar' | 'list' | 'note' | 'properties'
|
||||
|
||||
export type CompactNavigationState = {
|
||||
panel: CompactPanel
|
||||
selectedNoteId: string
|
||||
}
|
||||
|
||||
export type CompactNavigationEvent =
|
||||
| { type: 'backToList' }
|
||||
| { type: 'closeProperties' }
|
||||
| { type: 'closeSidebar' }
|
||||
| { type: 'openProperties' }
|
||||
| { type: 'openSidebar' }
|
||||
| { type: 'selectNote'; noteId: string }
|
||||
|
||||
export function createCompactNavigationState(initialNoteId: string): CompactNavigationState {
|
||||
return {
|
||||
panel: 'list',
|
||||
selectedNoteId: initialNoteId,
|
||||
}
|
||||
}
|
||||
|
||||
export function transitionCompactNavigation(
|
||||
state: CompactNavigationState,
|
||||
event: CompactNavigationEvent,
|
||||
): CompactNavigationState {
|
||||
if (event.type === 'selectNote') {
|
||||
return { panel: 'note', selectedNoteId: event.noteId }
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
panel: nextPanel(state.panel, event),
|
||||
}
|
||||
}
|
||||
|
||||
function nextPanel(currentPanel: CompactPanel, event: Exclude<CompactNavigationEvent, { type: 'selectNote' }>) {
|
||||
if (event.type === 'openSidebar') {
|
||||
return 'sidebar'
|
||||
}
|
||||
|
||||
if (event.type === 'closeSidebar' || event.type === 'backToList') {
|
||||
return 'list'
|
||||
}
|
||||
|
||||
if (event.type === 'openProperties') {
|
||||
return 'properties'
|
||||
}
|
||||
|
||||
if (event.type === 'closeProperties') {
|
||||
return 'note'
|
||||
}
|
||||
|
||||
return currentPanel
|
||||
}
|
||||
20
apps/mobile/src/demoData.test.ts
Normal file
20
apps/mobile/src/demoData.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { notes } from './demoData'
|
||||
import { createMobileSidebarSections } from './mobileSidebarNavigation'
|
||||
|
||||
describe('mobile demo data', () => {
|
||||
it('derives note titles and snippets through shared markdown utilities', () => {
|
||||
expect(notes[0].title).toBe('Workflow Orchestration Essay')
|
||||
expect(notes[0].snippet).toContain('The current narrative / temptation')
|
||||
expect(notes[0].words).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
it('keeps the initial sidebar focused on inbox', () => {
|
||||
const sidebarSections = createMobileSidebarSections(notes)
|
||||
|
||||
expect(sidebarSections[0].items[0]).toMatchObject({
|
||||
label: 'Inbox',
|
||||
selection: { kind: 'library', id: 'inbox' },
|
||||
})
|
||||
})
|
||||
})
|
||||
242
apps/mobile/src/demoData.ts
Normal file
242
apps/mobile/src/demoData.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { projectMobileNotes, type MobileNote, type MobileNoteSource } from './mobileNoteProjection'
|
||||
import { createFixtureMobileVaultRepository } from './mobileVaultRepository'
|
||||
|
||||
export type { MobileNote } from './mobileNoteProjection'
|
||||
|
||||
export const demoNoteSources: MobileNoteSource[] = [
|
||||
{
|
||||
archived: false,
|
||||
belongsTo: ['Tolaria MVP'],
|
||||
customProperties: { review_stage: 'Draft outline' },
|
||||
favorite: true,
|
||||
favoriteIndex: 0,
|
||||
has: ['workflow-orchestration-checklist'],
|
||||
id: 'workflow',
|
||||
type: 'Essay',
|
||||
icon: 'pen-nib',
|
||||
date: 'May 13, 2026',
|
||||
modified: '6h ago',
|
||||
filename: 'workflow.md',
|
||||
relatedTo: ['release', 'mobile-roadmap'],
|
||||
relationships: { people: ['Malte Ubl'], topics: ['workflow orchestration'] },
|
||||
tags: ['Design Inspiration', 'Tolaria MVP'],
|
||||
content: [
|
||||
'---',
|
||||
'title: Workflow Orchestration Essay',
|
||||
'_favorite: true',
|
||||
'_favorite_index: 0',
|
||||
'type: Essay',
|
||||
'status: Draft',
|
||||
'tags: [Design Inspiration, Tolaria MVP]',
|
||||
'belongs_to: [Tolaria MVP]',
|
||||
'related_to: [release, mobile-roadmap]',
|
||||
'has: [workflow-orchestration-checklist]',
|
||||
'people: [Malte Ubl]',
|
||||
'review_stage: Draft outline',
|
||||
'topics: [workflow orchestration]',
|
||||
'---',
|
||||
'',
|
||||
'# Workflow Orchestration Essay',
|
||||
'',
|
||||
'- The current narrative / temptation: everything routed through an LLM.',
|
||||
'- A real example (Tolaria + OpenClaw): OpenClaw does a lot for me in product development.',
|
||||
'- The cost of AI everywhere: expensive, slow, and unpredictable.',
|
||||
'- Where orchestration wins: observability, human-in-the-loop approvals, reliability.',
|
||||
'',
|
||||
'This connects to [[mobile-roadmap|the mobile roadmap]] and the [[workflow-orchestration-checklist]].',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
archived: false,
|
||||
belongsTo: ['Tolaria MVP'],
|
||||
customProperties: { platform: 'iPad first' },
|
||||
favorite: true,
|
||||
favoriteIndex: 1,
|
||||
has: ['workflow'],
|
||||
id: 'mobile-roadmap',
|
||||
type: 'Project',
|
||||
icon: 'wrench',
|
||||
date: 'May 5, 2026',
|
||||
modified: '1h ago',
|
||||
filename: 'mobile-roadmap.md',
|
||||
relatedTo: ['release'],
|
||||
relationships: { depends_on: ['workflow-orchestration-checklist'] },
|
||||
tags: ['Tolaria MVP', 'mobile'],
|
||||
content: [
|
||||
'---',
|
||||
'title: Mobile Roadmap',
|
||||
'_favorite: true',
|
||||
'_favorite_index: 1',
|
||||
'type: Project',
|
||||
'status: Active',
|
||||
'tags: [Tolaria MVP, mobile]',
|
||||
'belongs_to: [Tolaria MVP]',
|
||||
'related_to: [release]',
|
||||
'has: [workflow]',
|
||||
'depends_on: [workflow-orchestration-checklist]',
|
||||
'platform: iPad first',
|
||||
'---',
|
||||
'',
|
||||
'# Mobile Roadmap',
|
||||
'',
|
||||
'Local-first workflow parity comes before cloud sync.',
|
||||
'',
|
||||
'- Sidebar navigation by type',
|
||||
'- Raw editor for direct markdown edits',
|
||||
'- Wikilinks like [[workflow]] and [[release]]',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
archived: false,
|
||||
belongsTo: ['workflow'],
|
||||
customProperties: {},
|
||||
has: [],
|
||||
id: 'workflow-orchestration-checklist',
|
||||
type: 'Note',
|
||||
icon: 'file-text',
|
||||
date: 'May 5, 2026',
|
||||
modified: '2h ago',
|
||||
filename: 'workflow-orchestration-checklist.md',
|
||||
relatedTo: ['workflow'],
|
||||
relationships: {},
|
||||
tags: ['mobile'],
|
||||
content: [
|
||||
'---',
|
||||
'title: Workflow Orchestration Checklist',
|
||||
'type: Note',
|
||||
'status: Draft',
|
||||
'tags: [mobile]',
|
||||
'belongs_to: [workflow]',
|
||||
'related_to: [workflow]',
|
||||
'---',
|
||||
'',
|
||||
'# Workflow Orchestration Checklist',
|
||||
'',
|
||||
'- Validate breadcrumbs',
|
||||
'- Validate [[workflow]] wikilinks',
|
||||
'- Validate relationships panel',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
archived: false,
|
||||
belongsTo: ['Tolaria MVP'],
|
||||
customProperties: {},
|
||||
has: [],
|
||||
id: 'release',
|
||||
type: 'Release Note',
|
||||
icon: 'flag',
|
||||
date: 'May 2, 2026',
|
||||
modified: '12h ago',
|
||||
filename: 'release.md',
|
||||
relatedTo: ['workflow', 'mobile-roadmap'],
|
||||
relationships: {},
|
||||
tags: ['Release', 'Stable'],
|
||||
content: [
|
||||
'---',
|
||||
'title: v2026-05-02',
|
||||
'type: Release Note',
|
||||
'status: Done',
|
||||
'tags: [Release, Stable]',
|
||||
'belongs_to: [Tolaria MVP]',
|
||||
'related_to: [workflow, mobile-roadmap]',
|
||||
'---',
|
||||
'',
|
||||
'# v2026-05-02',
|
||||
'',
|
||||
'Another Tolaria release in the bag. This one is focused on performance, bug fixes, and lower-friction note workflows.',
|
||||
'',
|
||||
'Follow-up work lives in [[mobile-roadmap]].',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
archived: false,
|
||||
belongsTo: ['Tolaria MVP'],
|
||||
customProperties: {},
|
||||
has: ['resources'],
|
||||
id: 'migration',
|
||||
type: 'Project',
|
||||
icon: 'git-branch',
|
||||
date: 'Apr 28, 2026',
|
||||
modified: '1d ago',
|
||||
filename: 'migration.md',
|
||||
relatedTo: ['mobile-roadmap'],
|
||||
relationships: {},
|
||||
tags: ['Project', 'Resources'],
|
||||
content: [
|
||||
'---',
|
||||
'title: Tolaria <> Obsidian migration proposal',
|
||||
'type: Project',
|
||||
'status: Active',
|
||||
'tags: [Project, Resources]',
|
||||
'belongs_to: [Tolaria MVP]',
|
||||
'related_to: [mobile-roadmap]',
|
||||
'has: [resources]',
|
||||
'---',
|
||||
'',
|
||||
'# Tolaria <> Obsidian migration proposal',
|
||||
'',
|
||||
'Obsidian vaults are already close to Tolaria ideal substrate: local Markdown, portable attachments, and git-backed history.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
archived: false,
|
||||
belongsTo: ['migration'],
|
||||
customProperties: {},
|
||||
has: [],
|
||||
id: 'resources',
|
||||
type: 'Resource',
|
||||
icon: 'books',
|
||||
date: 'Apr 20, 2026',
|
||||
modified: '3d ago',
|
||||
filename: 'resources.md',
|
||||
relatedTo: ['migration', 'mobile-roadmap'],
|
||||
relationships: {},
|
||||
tags: ['Resources', 'mobile'],
|
||||
content: [
|
||||
'---',
|
||||
'title: Mobile Resources',
|
||||
'type: Resource',
|
||||
'status: Active',
|
||||
'tags: [Resources, mobile]',
|
||||
'belongs_to: [migration]',
|
||||
'related_to: [migration, mobile-roadmap]',
|
||||
'---',
|
||||
'',
|
||||
'# Mobile Resources',
|
||||
'',
|
||||
'References for [[mobile-roadmap]] and local vault workflow QA.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
archived: true,
|
||||
belongsTo: [],
|
||||
customProperties: {},
|
||||
has: [],
|
||||
id: 'old-mobile-spike',
|
||||
type: 'Note',
|
||||
icon: 'archive',
|
||||
date: 'Mar 30, 2026',
|
||||
modified: 'last month',
|
||||
filename: 'old-mobile-spike.md',
|
||||
relatedTo: ['mobile-roadmap'],
|
||||
relationships: {},
|
||||
tags: ['mobile'],
|
||||
content: [
|
||||
'---',
|
||||
'title: Old Mobile Spike',
|
||||
'type: Note',
|
||||
'archived: true',
|
||||
'tags: [mobile]',
|
||||
'related_to: [mobile-roadmap]',
|
||||
'---',
|
||||
'',
|
||||
'# Old Mobile Spike',
|
||||
'',
|
||||
'Archived prototype notes kept around for comparison with [[mobile-roadmap]].',
|
||||
].join('\n'),
|
||||
},
|
||||
]
|
||||
|
||||
export const notes: MobileNote[] = projectMobileNotes(demoNoteSources)
|
||||
|
||||
export const demoVaultRepository = createFixtureMobileVaultRepository(demoNoteSources)
|
||||
58
apps/mobile/src/mobileAiClient.test.ts
Normal file
58
apps/mobile/src/mobileAiClient.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { sendMobileAiRequest } from './mobileAiClient'
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
|
||||
describe('mobile AI client', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends an OpenAI-compatible chat completion request with note context', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
json: async () => ({ choices: [{ message: { content: 'Answer' } }] }),
|
||||
ok: true,
|
||||
} as Response)
|
||||
|
||||
await expect(sendMobileAiRequest({
|
||||
apiKey: 'key',
|
||||
note: note(),
|
||||
prompt: 'Summarize',
|
||||
provider: {
|
||||
baseUrl: 'https://api.example.com/v1/',
|
||||
id: 'provider',
|
||||
kind: 'open_ai_compatible',
|
||||
modelId: 'model',
|
||||
name: 'Provider',
|
||||
},
|
||||
})).resolves.toBe('Answer')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/chat/completions', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
function note(): MobileNote {
|
||||
return {
|
||||
archived: false,
|
||||
backlinks: [],
|
||||
belongsTo: [],
|
||||
content: '# Workflow\n\nBody',
|
||||
customProperties: {},
|
||||
date: '',
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
has: [],
|
||||
icon: 'file-text',
|
||||
id: 'workflow',
|
||||
modified: '',
|
||||
outgoingLinks: [],
|
||||
relatedTo: [],
|
||||
relationships: {},
|
||||
snippet: '',
|
||||
tags: [],
|
||||
title: 'Workflow',
|
||||
type: 'Note',
|
||||
words: 1,
|
||||
}
|
||||
}
|
||||
45
apps/mobile/src/mobileAiClient.ts
Normal file
45
apps/mobile/src/mobileAiClient.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
import type { MobileAiProvider } from './mobileAiSettings'
|
||||
|
||||
export type MobileAiRequest = {
|
||||
apiKey: string
|
||||
note: MobileNote
|
||||
prompt: string
|
||||
provider: MobileAiProvider
|
||||
}
|
||||
|
||||
export async function sendMobileAiRequest(request: MobileAiRequest) {
|
||||
const response = await fetch(`${request.provider.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
content: [
|
||||
'You are helping with a Tolaria markdown note.',
|
||||
'Use concise answers and preserve [[wikilink]] syntax when referencing notes.',
|
||||
`Active note: ${request.note.title}`,
|
||||
request.note.content,
|
||||
].join('\n\n'),
|
||||
role: 'system',
|
||||
},
|
||||
{ content: request.prompt, role: 'user' },
|
||||
],
|
||||
model: request.provider.modelId,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${request.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
return extractAssistantMessage(await response.json())
|
||||
}
|
||||
|
||||
function extractAssistantMessage(payload: unknown) {
|
||||
const content = (payload as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
25
apps/mobile/src/mobileAiProviderSecretStorage.test.ts
Normal file
25
apps/mobile/src/mobileAiProviderSecretStorage.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage'
|
||||
|
||||
describe('mobile AI provider secret storage', () => {
|
||||
it('stores API keys in secure provider-scoped records', async () => {
|
||||
const secrets = new Map<string, string>()
|
||||
const storage = createMobileAiProviderSecretStorage({
|
||||
deleteItemAsync: async (key) => {
|
||||
secrets.delete(key)
|
||||
},
|
||||
getItemAsync: async (key) => secrets.get(key) ?? null,
|
||||
setItemAsync: async (key, value) => {
|
||||
secrets.set(key, value)
|
||||
},
|
||||
})
|
||||
|
||||
await storage.saveApiKey('Open-AI', 'secret')
|
||||
|
||||
expect(await storage.loadApiKey('open-ai')).toBe('secret')
|
||||
|
||||
await storage.removeApiKey('open-ai')
|
||||
|
||||
expect(await storage.loadApiKey('open-ai')).toBeNull()
|
||||
})
|
||||
})
|
||||
29
apps/mobile/src/mobileAiProviderSecretStorage.ts
Normal file
29
apps/mobile/src/mobileAiProviderSecretStorage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type MobileAiProviderSecretStore = {
|
||||
deleteItemAsync: (key: string) => Promise<void>
|
||||
getItemAsync: (key: string) => Promise<string | null>
|
||||
setItemAsync: (key: string, value: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type MobileAiProviderSecretStorage = {
|
||||
loadApiKey: (providerId: string) => Promise<string | null>
|
||||
removeApiKey: (providerId: string) => Promise<void>
|
||||
saveApiKey: (providerId: string, apiKey: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function createMobileAiProviderSecretStorage(
|
||||
secureStore: MobileAiProviderSecretStore,
|
||||
): MobileAiProviderSecretStorage {
|
||||
return {
|
||||
loadApiKey: async (providerId) => secureStore.getItemAsync(providerKey(providerId)),
|
||||
removeApiKey: async (providerId) => {
|
||||
await secureStore.deleteItemAsync(providerKey(providerId))
|
||||
},
|
||||
saveApiKey: async (providerId, apiKey) => {
|
||||
await secureStore.setItemAsync(providerKey(providerId), apiKey)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function providerKey(providerId: string) {
|
||||
return ['tolaria', 'ai-provider', providerId.trim().toLowerCase(), 'api-key'].join(':')
|
||||
}
|
||||
66
apps/mobile/src/mobileAiSettings.test.ts
Normal file
66
apps/mobile/src/mobileAiSettings.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildMobileAiProvider,
|
||||
normalizeMobileAiSettings,
|
||||
removeMobileAiProvider,
|
||||
selectedMobileAiProvider,
|
||||
upsertMobileAiProvider,
|
||||
} from './mobileAiSettings'
|
||||
|
||||
describe('mobile AI settings', () => {
|
||||
it('builds API providers from presets without storing API keys', () => {
|
||||
expect(buildMobileAiProvider({
|
||||
draft: {
|
||||
apiKey: 'secret',
|
||||
kind: 'open_ai',
|
||||
modelId: ' gpt-4.1-mini ',
|
||||
name: '',
|
||||
},
|
||||
providerId: 'open-ai',
|
||||
})).toEqual({
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
id: 'open-ai',
|
||||
kind: 'open_ai',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
name: 'OpenAI',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes persisted settings and selected provider', () => {
|
||||
const settings = normalizeMobileAiSettings({
|
||||
defaultProviderId: 'open-ai',
|
||||
providers: [{
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
id: 'open-ai',
|
||||
kind: 'open_ai',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
name: 'OpenAI',
|
||||
}],
|
||||
})
|
||||
|
||||
expect(selectedMobileAiProvider(settings)?.id).toBe('open-ai')
|
||||
})
|
||||
|
||||
it('upserts and removes providers while maintaining the default target', () => {
|
||||
const settings = upsertMobileAiProvider({
|
||||
provider: provider('one'),
|
||||
settings: { defaultProviderId: null, providers: [] },
|
||||
})
|
||||
|
||||
expect(settings.defaultProviderId).toBe('one')
|
||||
expect(removeMobileAiProvider({ providerId: 'one', settings })).toEqual({
|
||||
defaultProviderId: null,
|
||||
providers: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function provider(id: string) {
|
||||
return {
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
id,
|
||||
kind: 'open_ai' as const,
|
||||
modelId: 'gpt-4.1-mini',
|
||||
name: 'OpenAI',
|
||||
}
|
||||
}
|
||||
166
apps/mobile/src/mobileAiSettings.ts
Normal file
166
apps/mobile/src/mobileAiSettings.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
export type MobileAiProviderKind = 'anthropic' | 'gemini' | 'open_ai' | 'open_ai_compatible' | 'open_router'
|
||||
|
||||
export type MobileAiProvider = {
|
||||
baseUrl: string
|
||||
id: string
|
||||
kind: MobileAiProviderKind
|
||||
modelId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type MobileAiSettings = {
|
||||
defaultProviderId: string | null
|
||||
providers: MobileAiProvider[]
|
||||
}
|
||||
|
||||
export type MobileAiProviderDraft = {
|
||||
apiKey: string
|
||||
kind: MobileAiProviderKind
|
||||
modelId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const defaultMobileAiSettings: MobileAiSettings = {
|
||||
defaultProviderId: null,
|
||||
providers: [],
|
||||
}
|
||||
|
||||
export const mobileAiProviderPresets: Record<MobileAiProviderKind, { baseUrl: string; name: string; placeholder: string }> = {
|
||||
anthropic: {
|
||||
baseUrl: 'https://api.anthropic.com/v1',
|
||||
name: 'Anthropic',
|
||||
placeholder: 'claude-3-5-sonnet-latest',
|
||||
},
|
||||
gemini: {
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
name: 'Gemini',
|
||||
placeholder: 'gemini-2.5-flash',
|
||||
},
|
||||
open_ai: {
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
name: 'OpenAI',
|
||||
placeholder: 'gpt-4.1-mini',
|
||||
},
|
||||
open_ai_compatible: {
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
name: 'Custom provider',
|
||||
placeholder: 'model-id',
|
||||
},
|
||||
open_router: {
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
name: 'OpenRouter',
|
||||
placeholder: 'openai/gpt-4.1-mini',
|
||||
},
|
||||
}
|
||||
|
||||
export function buildMobileAiProvider({
|
||||
draft,
|
||||
providerId,
|
||||
}: {
|
||||
draft: MobileAiProviderDraft
|
||||
providerId: string
|
||||
}): MobileAiProvider {
|
||||
const preset = mobileAiProviderPresets[draft.kind]
|
||||
return {
|
||||
baseUrl: preset.baseUrl,
|
||||
id: providerId,
|
||||
kind: draft.kind,
|
||||
modelId: draft.modelId.trim(),
|
||||
name: draft.name.trim() || preset.name,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMobileAiSettings(value: unknown): MobileAiSettings {
|
||||
if (!isSettingsRecord(value)) {
|
||||
return defaultMobileAiSettings
|
||||
}
|
||||
|
||||
const providers = value.providers.flatMap(normalizeMobileAiProvider)
|
||||
return {
|
||||
defaultProviderId: defaultProviderId({ providers, value: value.defaultProviderId }),
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedMobileAiProvider(settings: MobileAiSettings) {
|
||||
return settings.providers.find((provider) => provider.id === settings.defaultProviderId) ?? settings.providers[0] ?? null
|
||||
}
|
||||
|
||||
export function upsertMobileAiProvider({
|
||||
provider,
|
||||
settings,
|
||||
}: {
|
||||
provider: MobileAiProvider
|
||||
settings: MobileAiSettings
|
||||
}): MobileAiSettings {
|
||||
const providers = [provider, ...settings.providers.filter((item) => item.id !== provider.id)]
|
||||
return { defaultProviderId: provider.id, providers }
|
||||
}
|
||||
|
||||
export function removeMobileAiProvider({
|
||||
providerId,
|
||||
settings,
|
||||
}: {
|
||||
providerId: string
|
||||
settings: MobileAiSettings
|
||||
}): MobileAiSettings {
|
||||
const providers = settings.providers.filter((provider) => provider.id !== providerId)
|
||||
return {
|
||||
defaultProviderId: settings.defaultProviderId === providerId ? providers[0]?.id ?? null : settings.defaultProviderId,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProviderId({
|
||||
providers,
|
||||
value,
|
||||
}: {
|
||||
providers: MobileAiProvider[]
|
||||
value: unknown
|
||||
}) {
|
||||
return typeof value === 'string' && providers.some((provider) => provider.id === value)
|
||||
? value
|
||||
: providers[0]?.id ?? null
|
||||
}
|
||||
|
||||
function normalizeMobileAiProvider(value: unknown): MobileAiProvider[] {
|
||||
if (!isProviderRecord(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
baseUrl: value.baseUrl.trim(),
|
||||
id: value.id.trim(),
|
||||
kind: value.kind,
|
||||
modelId: value.modelId.trim(),
|
||||
name: value.name.trim(),
|
||||
}]
|
||||
}
|
||||
|
||||
function isSettingsRecord(value: unknown): value is { defaultProviderId?: unknown; providers: unknown[] } {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& Array.isArray((value as { providers?: unknown }).providers)
|
||||
}
|
||||
|
||||
function isProviderRecord(value: unknown): value is MobileAiProvider {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& hasText((value as MobileAiProvider).baseUrl)
|
||||
&& hasText((value as MobileAiProvider).id)
|
||||
&& isProviderKind((value as MobileAiProvider).kind)
|
||||
&& hasText((value as MobileAiProvider).modelId)
|
||||
&& hasText((value as MobileAiProvider).name)
|
||||
}
|
||||
|
||||
function hasText(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function isProviderKind(value: unknown): value is MobileAiProviderKind {
|
||||
return value === 'anthropic'
|
||||
|| value === 'gemini'
|
||||
|| value === 'open_ai'
|
||||
|| value === 'open_ai_compatible'
|
||||
|| value === 'open_router'
|
||||
}
|
||||
34
apps/mobile/src/mobileAiSettingsStorage.test.ts
Normal file
34
apps/mobile/src/mobileAiSettingsStorage.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMobileAiSettingsStorage } from './mobileAiSettingsStorage'
|
||||
|
||||
describe('mobile AI settings storage', () => {
|
||||
it('loads default settings when no file exists and saves normalized settings', async () => {
|
||||
const files = new Map<string, string>()
|
||||
const storage = createMobileAiSettingsStorage({
|
||||
documentDirectory: 'file:///docs',
|
||||
getInfoAsync: async (uri) => ({ exists: files.has(uri) }),
|
||||
makeDirectoryAsync: async (uri) => {
|
||||
files.set(uri, '')
|
||||
},
|
||||
readAsStringAsync: async (uri) => files.get(uri) ?? '',
|
||||
writeAsStringAsync: async (uri, content) => {
|
||||
files.set(uri, content)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await storage.load()).toEqual({ defaultProviderId: null, providers: [] })
|
||||
|
||||
await storage.save({
|
||||
defaultProviderId: 'provider',
|
||||
providers: [{
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
id: 'provider',
|
||||
kind: 'open_ai',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
name: 'OpenAI',
|
||||
}],
|
||||
})
|
||||
|
||||
expect(await storage.load()).toMatchObject({ defaultProviderId: 'provider' })
|
||||
})
|
||||
})
|
||||
82
apps/mobile/src/mobileAiSettingsStorage.ts
Normal file
82
apps/mobile/src/mobileAiSettingsStorage.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { defaultMobileAiSettings, normalizeMobileAiSettings, type MobileAiSettings } from './mobileAiSettings'
|
||||
|
||||
export type MobileAiSettingsFileInfo = {
|
||||
exists: boolean
|
||||
isDirectory?: boolean
|
||||
}
|
||||
|
||||
export type MobileAiSettingsFileSystem = {
|
||||
documentDirectory: string | null
|
||||
getInfoAsync: (uri: string) => Promise<MobileAiSettingsFileInfo>
|
||||
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
|
||||
readAsStringAsync: (uri: string) => Promise<string>
|
||||
writeAsStringAsync: (uri: string, content: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type MobileAiSettingsStorage = {
|
||||
load: () => Promise<MobileAiSettings>
|
||||
save: (settings: MobileAiSettings) => Promise<void>
|
||||
}
|
||||
|
||||
export function createMobileAiSettingsStorage(
|
||||
fileSystem: MobileAiSettingsFileSystem,
|
||||
): MobileAiSettingsStorage {
|
||||
return {
|
||||
load: async () => loadMobileAiSettings(fileSystem),
|
||||
save: async (settings) => saveMobileAiSettings({ fileSystem, settings }),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMobileAiSettings(fileSystem: MobileAiSettingsFileSystem) {
|
||||
const fileUri = settingsFileUri(fileSystem)
|
||||
const info = await fileSystem.getInfoAsync(fileUri)
|
||||
if (!info.exists || info.isDirectory) {
|
||||
return defaultMobileAiSettings
|
||||
}
|
||||
|
||||
return parseMobileAiSettings(await fileSystem.readAsStringAsync(fileUri))
|
||||
}
|
||||
|
||||
async function saveMobileAiSettings({
|
||||
fileSystem,
|
||||
settings,
|
||||
}: {
|
||||
fileSystem: MobileAiSettingsFileSystem
|
||||
settings: MobileAiSettings
|
||||
}) {
|
||||
await ensureDirectory({ fileSystem, uri: settingsRootUri(fileSystem) })
|
||||
await fileSystem.writeAsStringAsync(settingsFileUri(fileSystem), JSON.stringify(settings))
|
||||
}
|
||||
|
||||
function parseMobileAiSettings(content: string) {
|
||||
try {
|
||||
return normalizeMobileAiSettings(JSON.parse(content))
|
||||
} catch {
|
||||
return defaultMobileAiSettings
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDirectory({
|
||||
fileSystem,
|
||||
uri,
|
||||
}: {
|
||||
fileSystem: MobileAiSettingsFileSystem
|
||||
uri: string
|
||||
}) {
|
||||
const info = await fileSystem.getInfoAsync(uri)
|
||||
if (!info.exists) {
|
||||
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
|
||||
}
|
||||
}
|
||||
|
||||
function settingsFileUri(fileSystem: MobileAiSettingsFileSystem) {
|
||||
return `${settingsRootUri(fileSystem)}/ai-settings.json`
|
||||
}
|
||||
|
||||
function settingsRootUri(fileSystem: MobileAiSettingsFileSystem) {
|
||||
if (!fileSystem.documentDirectory) {
|
||||
throw new Error('Expo FileSystem documentDirectory is unavailable')
|
||||
}
|
||||
|
||||
return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state`
|
||||
}
|
||||
60
apps/mobile/src/mobileAppStateStorage.test.ts
Normal file
60
apps/mobile/src/mobileAppStateStorage.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createMobileAppStateStorage,
|
||||
type MobileAppStateFileSystem,
|
||||
} from './mobileAppStateStorage'
|
||||
|
||||
describe('mobile app state storage', () => {
|
||||
it('returns default state when no app state file exists', async () => {
|
||||
const storage = createMobileAppStateStorage(createMemoryAppStateFileSystem())
|
||||
|
||||
await expect(storage.load('personal')).resolves.toEqual({
|
||||
activeVaultId: 'personal',
|
||||
selectedNoteId: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('persists and restores selected note id for the active vault', async () => {
|
||||
const fileSystem = createMemoryAppStateFileSystem()
|
||||
const storage = createMobileAppStateStorage(fileSystem)
|
||||
|
||||
await storage.save({ activeVaultId: 'personal', selectedNoteId: 'workflow' })
|
||||
|
||||
await expect(storage.load('personal')).resolves.toEqual({
|
||||
activeVaultId: 'personal',
|
||||
selectedNoteId: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores corrupt or mismatched state files', async () => {
|
||||
const fileSystem = createMemoryAppStateFileSystem({
|
||||
'file:///docs/state/app-state.json': '{"activeVaultId":"other","selectedNoteId":"workflow"}',
|
||||
})
|
||||
const storage = createMobileAppStateStorage(fileSystem)
|
||||
|
||||
await expect(storage.load('personal')).resolves.toEqual({
|
||||
activeVaultId: 'personal',
|
||||
selectedNoteId: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createMemoryAppStateFileSystem(files: Record<string, string> = {}): MobileAppStateFileSystem {
|
||||
const fileByUri = new Map(Object.entries(files))
|
||||
const directoryUris = new Set(['file:///docs'])
|
||||
|
||||
return {
|
||||
documentDirectory: 'file:///docs/',
|
||||
getInfoAsync: async (uri) => ({
|
||||
exists: fileByUri.has(uri) || directoryUris.has(uri),
|
||||
isDirectory: directoryUris.has(uri),
|
||||
}),
|
||||
makeDirectoryAsync: async (uri) => {
|
||||
directoryUris.add(uri)
|
||||
},
|
||||
readAsStringAsync: async (uri) => fileByUri.get(uri) ?? '',
|
||||
writeAsStringAsync: async (uri, content) => {
|
||||
fileByUri.set(uri, content)
|
||||
},
|
||||
}
|
||||
}
|
||||
126
apps/mobile/src/mobileAppStateStorage.ts
Normal file
126
apps/mobile/src/mobileAppStateStorage.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
export type MobileAppState = {
|
||||
activeVaultId: string
|
||||
selectedNoteId: string | null
|
||||
}
|
||||
|
||||
export type MobileAppStateFileInfo = {
|
||||
exists: boolean
|
||||
isDirectory?: boolean
|
||||
}
|
||||
|
||||
export type MobileAppStateFileSystem = {
|
||||
documentDirectory: string | null
|
||||
getInfoAsync: (uri: string) => Promise<MobileAppStateFileInfo>
|
||||
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
|
||||
readAsStringAsync: (uri: string) => Promise<string>
|
||||
writeAsStringAsync: (uri: string, content: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type MobileAppStateStorage = {
|
||||
load: (activeVaultId: string) => Promise<MobileAppState>
|
||||
save: (state: MobileAppState) => Promise<void>
|
||||
}
|
||||
|
||||
export function createMobileAppStateStorage(
|
||||
fileSystem: MobileAppStateFileSystem,
|
||||
): MobileAppStateStorage {
|
||||
return {
|
||||
load: async (activeVaultId) => loadMobileAppState({ activeVaultId, fileSystem }),
|
||||
save: async (state) => saveMobileAppState({ fileSystem, state }),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMobileAppState({
|
||||
activeVaultId,
|
||||
fileSystem,
|
||||
}: {
|
||||
activeVaultId: string
|
||||
fileSystem: MobileAppStateFileSystem
|
||||
}) {
|
||||
const fileUri = appStateFileUri(fileSystem)
|
||||
const info = await fileSystem.getInfoAsync(fileUri)
|
||||
if (!info.exists || info.isDirectory) {
|
||||
return defaultMobileAppState(activeVaultId)
|
||||
}
|
||||
|
||||
return parseMobileAppState({
|
||||
activeVaultId,
|
||||
content: await fileSystem.readAsStringAsync(fileUri),
|
||||
})
|
||||
}
|
||||
|
||||
async function saveMobileAppState({
|
||||
fileSystem,
|
||||
state,
|
||||
}: {
|
||||
fileSystem: MobileAppStateFileSystem
|
||||
state: MobileAppState
|
||||
}) {
|
||||
const rootUri = appStateRootUri(fileSystem)
|
||||
await ensureDirectory({ fileSystem, uri: rootUri })
|
||||
await fileSystem.writeAsStringAsync(appStateFileUri(fileSystem), JSON.stringify(state))
|
||||
}
|
||||
|
||||
function parseMobileAppState({
|
||||
activeVaultId,
|
||||
content,
|
||||
}: {
|
||||
activeVaultId: string
|
||||
content: string
|
||||
}) {
|
||||
try {
|
||||
return coerceMobileAppState({ activeVaultId, value: JSON.parse(content) })
|
||||
} catch {
|
||||
return defaultMobileAppState(activeVaultId)
|
||||
}
|
||||
}
|
||||
|
||||
function coerceMobileAppState({
|
||||
activeVaultId,
|
||||
value,
|
||||
}: {
|
||||
activeVaultId: string
|
||||
value: unknown
|
||||
}): MobileAppState {
|
||||
if (!isStateRecord(value) || value.activeVaultId !== activeVaultId) {
|
||||
return defaultMobileAppState(activeVaultId)
|
||||
}
|
||||
|
||||
return {
|
||||
activeVaultId,
|
||||
selectedNoteId: typeof value.selectedNoteId === 'string' ? value.selectedNoteId : null,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMobileAppState(activeVaultId: string): MobileAppState {
|
||||
return { activeVaultId, selectedNoteId: null }
|
||||
}
|
||||
|
||||
async function ensureDirectory({
|
||||
fileSystem,
|
||||
uri,
|
||||
}: {
|
||||
fileSystem: MobileAppStateFileSystem
|
||||
uri: string
|
||||
}) {
|
||||
const info = await fileSystem.getInfoAsync(uri)
|
||||
if (!info.exists) {
|
||||
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
|
||||
}
|
||||
}
|
||||
|
||||
function appStateFileUri(fileSystem: MobileAppStateFileSystem) {
|
||||
return `${appStateRootUri(fileSystem)}/app-state.json`
|
||||
}
|
||||
|
||||
function appStateRootUri(fileSystem: MobileAppStateFileSystem) {
|
||||
if (!fileSystem.documentDirectory) {
|
||||
throw new Error('Expo FileSystem documentDirectory is unavailable')
|
||||
}
|
||||
|
||||
return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state`
|
||||
}
|
||||
|
||||
function isStateRecord(value: unknown): value is { activeVaultId?: unknown; selectedNoteId?: unknown } {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
125
apps/mobile/src/mobileAutosaveQueue.test.ts
Normal file
125
apps/mobile/src/mobileAutosaveQueue.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
|
||||
import { createMobileAutosaveQueue, type MobileAutosaveScheduler } from './mobileAutosaveQueue'
|
||||
import type { MobileEditorSaveState } from './mobileEditorSaveState'
|
||||
|
||||
describe('mobile autosave queue', () => {
|
||||
it('debounces drafts for the same note', async () => {
|
||||
const scheduler = createManualScheduler()
|
||||
const savedDrafts: MobileEditorDraft[] = []
|
||||
const states: string[] = []
|
||||
const queue = createMobileAutosaveQueue({
|
||||
delayMs: 300,
|
||||
scheduler,
|
||||
onStateChange: (_noteId, state) => states.push(state.state),
|
||||
saveDraft: async (draft) => {
|
||||
savedDrafts.push(draft)
|
||||
return { status: 'saved', path: `${draft.noteId}.md` }
|
||||
},
|
||||
})
|
||||
|
||||
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
|
||||
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>Second</p>'))
|
||||
|
||||
await scheduler.flush()
|
||||
|
||||
expect(savedDrafts).toHaveLength(1)
|
||||
expect(savedDrafts[0]).toMatchObject({ canonicalMarkdown: '# Workflow\n\nSecond' })
|
||||
expect(states).toEqual(['queued', 'queued', 'saving', 'saved'])
|
||||
})
|
||||
|
||||
it('ignores stale save results when a newer draft was queued', async () => {
|
||||
const scheduler = createManualScheduler()
|
||||
const savedMarkdown: string[] = []
|
||||
const states: MobileEditorSaveState[] = []
|
||||
let resolveFirstSave: () => void = () => {
|
||||
throw new Error('First save was not scheduled')
|
||||
}
|
||||
const queue = createMobileAutosaveQueue({
|
||||
delayMs: 300,
|
||||
scheduler,
|
||||
onSavedDraft: (draft) => savedMarkdown.push(draft.canonicalMarkdown),
|
||||
onStateChange: (_noteId, state) => states.push(state),
|
||||
saveDraft: (draft) =>
|
||||
draftMarkdown(draft).includes('First')
|
||||
? new Promise((resolve) => {
|
||||
resolveFirstSave = () => resolve({ status: 'saved', path: `${draft.noteId}.md` })
|
||||
})
|
||||
: Promise.resolve({ status: 'saved', path: `${draft.noteId}.md` }),
|
||||
})
|
||||
|
||||
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
|
||||
await scheduler.flush()
|
||||
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>Second</p>'))
|
||||
resolveFirstSave()
|
||||
await Promise.resolve()
|
||||
await scheduler.flush()
|
||||
|
||||
expect(states.map((state) => state.state)).toEqual(['queued', 'saving', 'queued', 'saving', 'saved'])
|
||||
expect(savedMarkdown).toEqual(['# Workflow\n\nSecond'])
|
||||
})
|
||||
|
||||
it('can cancel pending draft saves', async () => {
|
||||
const scheduler = createManualScheduler()
|
||||
const savedDrafts: MobileEditorDraft[] = []
|
||||
const queue = createMobileAutosaveQueue({
|
||||
delayMs: 300,
|
||||
scheduler,
|
||||
onStateChange: () => {},
|
||||
saveDraft: async (draft) => {
|
||||
savedDrafts.push(draft)
|
||||
return { status: 'saved', path: `${draft.noteId}.md` }
|
||||
},
|
||||
})
|
||||
|
||||
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
|
||||
queue.cancelAll()
|
||||
await scheduler.flush()
|
||||
|
||||
expect(savedDrafts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function draftFor(noteId: string, editorHtml: string) {
|
||||
return createMobileEditorDraft({
|
||||
note: { id: noteId, title: 'Workflow', content: '# Workflow' },
|
||||
editorHtml,
|
||||
})
|
||||
}
|
||||
|
||||
function draftMarkdown(draft: MobileEditorDraft) {
|
||||
if (!draft.persistable) {
|
||||
throw new Error('Expected persistable test draft')
|
||||
}
|
||||
|
||||
return draft.canonicalMarkdown
|
||||
}
|
||||
|
||||
function createManualScheduler() {
|
||||
type ManualTimer = {
|
||||
callback: () => void
|
||||
active: boolean
|
||||
}
|
||||
const timers: ManualTimer[] = []
|
||||
const scheduler: MobileAutosaveScheduler & { flush: () => Promise<void> } = {
|
||||
set: (callback) => {
|
||||
const timer = { callback, active: true }
|
||||
timers.push(timer)
|
||||
|
||||
return timer
|
||||
},
|
||||
clear: (timer) => {
|
||||
const manualTimer = timer as unknown as ManualTimer
|
||||
manualTimer.active = false
|
||||
},
|
||||
flush: async () => {
|
||||
const activeTimers = timers.splice(0).filter((timer) => timer.active)
|
||||
for (const timer of activeTimers) {
|
||||
timer.callback()
|
||||
}
|
||||
await Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
return scheduler
|
||||
}
|
||||
137
apps/mobile/src/mobileAutosaveQueue.ts
Normal file
137
apps/mobile/src/mobileAutosaveQueue.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { MobileEditorDraft } from './mobileEditorDraft'
|
||||
import type { MobileEditorDraftSaveResult } from './mobileEditorDraftSave'
|
||||
import {
|
||||
queuedMobileEditorSaveState,
|
||||
saveResultState,
|
||||
savingMobileEditorSaveState,
|
||||
failedMobileEditorSaveState,
|
||||
type MobileEditorSaveState,
|
||||
} from './mobileEditorSaveState'
|
||||
|
||||
export type MobileAutosaveTimer = unknown
|
||||
|
||||
export type MobileAutosaveScheduler = {
|
||||
set: (callback: () => void, delayMs: number) => MobileAutosaveTimer
|
||||
clear: (timer: MobileAutosaveTimer) => void
|
||||
}
|
||||
|
||||
export type MobileAutosaveQueue = {
|
||||
enqueue: (draft: MobileEditorDraft) => void
|
||||
cancelAll: () => void
|
||||
}
|
||||
|
||||
export type SavedMobileEditorDraft = Extract<MobileEditorDraft, { persistable: true }>
|
||||
|
||||
export function createMobileAutosaveQueue({
|
||||
delayMs,
|
||||
onSavedDraft,
|
||||
onStateChange,
|
||||
saveDraft,
|
||||
scheduler = nativeScheduler,
|
||||
}: {
|
||||
delayMs: number
|
||||
onSavedDraft?: (draft: SavedMobileEditorDraft) => void
|
||||
onStateChange: (noteId: string, state: MobileEditorSaveState) => void
|
||||
saveDraft: (draft: MobileEditorDraft) => Promise<MobileEditorDraftSaveResult>
|
||||
scheduler?: MobileAutosaveScheduler
|
||||
}): MobileAutosaveQueue {
|
||||
const generationByNoteId = new Map<string, number>()
|
||||
const timerByNoteId = new Map<string, MobileAutosaveTimer>()
|
||||
|
||||
return {
|
||||
enqueue: (draft) => {
|
||||
const generation = nextGeneration({ draft, generationByNoteId })
|
||||
clearPendingTimer({ draft, scheduler, timerByNoteId })
|
||||
onStateChange(draft.noteId, queuedMobileEditorSaveState)
|
||||
timerByNoteId.set(
|
||||
draft.noteId,
|
||||
scheduler.set(() => {
|
||||
timerByNoteId.delete(draft.noteId)
|
||||
void saveLatestDraft({ draft, generation, generationByNoteId, onSavedDraft, onStateChange, saveDraft })
|
||||
}, delayMs),
|
||||
)
|
||||
},
|
||||
cancelAll: () => {
|
||||
for (const timer of timerByNoteId.values()) {
|
||||
scheduler.clear(timer)
|
||||
}
|
||||
timerByNoteId.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const nativeScheduler: MobileAutosaveScheduler = {
|
||||
set: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clear: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
||||
}
|
||||
|
||||
async function saveLatestDraft({
|
||||
draft,
|
||||
generation,
|
||||
generationByNoteId,
|
||||
onSavedDraft,
|
||||
onStateChange,
|
||||
saveDraft,
|
||||
}: {
|
||||
draft: MobileEditorDraft
|
||||
generation: number
|
||||
generationByNoteId: Map<string, number>
|
||||
onSavedDraft?: (draft: SavedMobileEditorDraft) => void
|
||||
onStateChange: (noteId: string, state: MobileEditorSaveState) => void
|
||||
saveDraft: (draft: MobileEditorDraft) => Promise<MobileEditorDraftSaveResult>
|
||||
}) {
|
||||
onStateChange(draft.noteId, savingMobileEditorSaveState)
|
||||
try {
|
||||
const result = await saveDraft(draft)
|
||||
if (isLatestGeneration({ draft, generation, generationByNoteId })) {
|
||||
onStateChange(draft.noteId, saveResultState(result))
|
||||
if (result.status === 'saved' && draft.persistable) {
|
||||
onSavedDraft?.(draft)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (isLatestGeneration({ draft, generation, generationByNoteId })) {
|
||||
onStateChange(draft.noteId, failedMobileEditorSaveState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nextGeneration({
|
||||
draft,
|
||||
generationByNoteId,
|
||||
}: {
|
||||
draft: MobileEditorDraft
|
||||
generationByNoteId: Map<string, number>
|
||||
}) {
|
||||
const generation = (generationByNoteId.get(draft.noteId) ?? 0) + 1
|
||||
generationByNoteId.set(draft.noteId, generation)
|
||||
|
||||
return generation
|
||||
}
|
||||
|
||||
function clearPendingTimer({
|
||||
draft,
|
||||
scheduler,
|
||||
timerByNoteId,
|
||||
}: {
|
||||
draft: MobileEditorDraft
|
||||
scheduler: MobileAutosaveScheduler
|
||||
timerByNoteId: Map<string, MobileAutosaveTimer>
|
||||
}) {
|
||||
const timer = timerByNoteId.get(draft.noteId)
|
||||
if (timer) {
|
||||
scheduler.clear(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function isLatestGeneration({
|
||||
draft,
|
||||
generation,
|
||||
generationByNoteId,
|
||||
}: {
|
||||
draft: MobileEditorDraft
|
||||
generation: number
|
||||
generationByNoteId: Map<string, number>
|
||||
}) {
|
||||
return generationByNoteId.get(draft.noteId) === generation
|
||||
}
|
||||
128
apps/mobile/src/mobileCoreFlowSmoke.test.ts
Normal file
128
apps/mobile/src/mobileCoreFlowSmoke.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMobileEditorDraft } from './mobileEditorDraft'
|
||||
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
|
||||
import { createMobileNoteFile } from './mobileNoteCreate'
|
||||
import { saveMobileNoteFrontmatter } from './mobileNoteFrontmatterSave'
|
||||
import { createMobileVaultConfig, type MobileVaultConfig } from './mobileVaultConfig'
|
||||
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
|
||||
import { createMemoryMobileVaultStorage, type MobileVaultStorageDriver } from './mobileVaultStorage'
|
||||
|
||||
describe('mobile core flow smoke', () => {
|
||||
it('creates, opens, edits, updates properties, and deletes an app-local note', async () => {
|
||||
const vault = createVault()
|
||||
const storage = createMemoryMobileVaultStorage([])
|
||||
const noteId = await createNote({ storage, vault })
|
||||
|
||||
const openedNote = await readNote({ noteId, storage, vault })
|
||||
expect(openedNote).toMatchObject({ id: noteId, title: 'Morning Plan', type: 'Note' })
|
||||
|
||||
await saveEditorContent({ note: openedNote, storage, vault })
|
||||
await expect(storage.readMarkdownFile(vault, `${noteId}.md`)).resolves.toBe([
|
||||
'---',
|
||||
'title: Morning Plan',
|
||||
'type: Note',
|
||||
'created: 2026-05-05T08:00:00.000Z',
|
||||
'---',
|
||||
'# Morning Plan',
|
||||
'',
|
||||
'Edited agenda',
|
||||
'',
|
||||
'> Follow up',
|
||||
].join('\n'))
|
||||
|
||||
await saveMobileNoteFrontmatter({
|
||||
metadata: {
|
||||
date: '5 May 2026',
|
||||
icon: 'wrench',
|
||||
status: 'Active',
|
||||
tags: ['Tolaria MVP', 'mobile'],
|
||||
type: 'Project',
|
||||
},
|
||||
noteId,
|
||||
storage,
|
||||
vault,
|
||||
})
|
||||
|
||||
await expect(readNote({ noteId, storage, vault })).resolves.toMatchObject({
|
||||
date: '5 May 2026',
|
||||
icon: 'wrench',
|
||||
status: 'Active',
|
||||
tags: ['Tolaria MVP', 'mobile'],
|
||||
type: 'Project',
|
||||
})
|
||||
|
||||
await repository({ storage, vault }).deleteNote(noteId)
|
||||
await expect(repository({ storage, vault }).readNote(noteId)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
async function createNote({
|
||||
storage,
|
||||
vault,
|
||||
}: {
|
||||
storage: MobileVaultStorageDriver
|
||||
vault: MobileVaultConfig
|
||||
}) {
|
||||
const file = createMobileNoteFile({
|
||||
now: new Date('2026-05-05T08:00:00.000Z'),
|
||||
title: 'Morning Plan',
|
||||
})
|
||||
await storage.writeMarkdownFile(vault, file.path, file.content)
|
||||
|
||||
return file.path.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
async function readNote({
|
||||
noteId,
|
||||
storage,
|
||||
vault,
|
||||
}: {
|
||||
noteId: string
|
||||
storage: MobileVaultStorageDriver
|
||||
vault: MobileVaultConfig
|
||||
}) {
|
||||
const note = await repository({ storage, vault }).readNote(noteId)
|
||||
if (!note) {
|
||||
throw new Error(`Expected note ${noteId}`)
|
||||
}
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
async function saveEditorContent({
|
||||
note,
|
||||
storage,
|
||||
vault,
|
||||
}: {
|
||||
note: Awaited<ReturnType<typeof readNote>>
|
||||
storage: MobileVaultStorageDriver
|
||||
vault: MobileVaultConfig
|
||||
}) {
|
||||
await saveMobileEditorDraft({
|
||||
draft: createMobileEditorDraft({
|
||||
note,
|
||||
editorHtml: '<h1>Morning Plan</h1><p>Edited agenda</p><blockquote><p>Follow up</p></blockquote>',
|
||||
}),
|
||||
storage,
|
||||
vault,
|
||||
})
|
||||
}
|
||||
|
||||
function repository({
|
||||
storage,
|
||||
vault,
|
||||
}: {
|
||||
storage: MobileVaultStorageDriver
|
||||
vault: MobileVaultConfig
|
||||
}) {
|
||||
return createStoredMobileVaultRepository({ storage, vault })
|
||||
}
|
||||
|
||||
function createVault() {
|
||||
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.config
|
||||
}
|
||||
16
apps/mobile/src/mobileDemoVault.test.ts
Normal file
16
apps/mobile/src/mobileDemoVault.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
|
||||
|
||||
describe('mobile demo vault loading', () => {
|
||||
it('does not seed demo notes into remote-backed vaults', () => {
|
||||
expect(shouldSeedDemoVault({
|
||||
id: 'personal',
|
||||
name: 'Personal Journal',
|
||||
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps local-only vaults seeded for first-run simulator QA', () => {
|
||||
expect(shouldSeedDemoVault({ id: 'personal', name: 'Personal Journal' })).toBe(true)
|
||||
})
|
||||
})
|
||||
116
apps/mobile/src/mobileDemoVault.ts
Normal file
116
apps/mobile/src/mobileDemoVault.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { demoNoteSources } from './demoData'
|
||||
import type { MobileEditorDraft } from './mobileEditorDraft'
|
||||
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
|
||||
import { saveMobileNoteFrontmatter } from './mobileNoteFrontmatterSave'
|
||||
import type { WritableMobileNoteFrontmatter } from './mobileNoteFrontmatterWrite'
|
||||
import { createMobileNoteFile } from './mobileNoteCreate'
|
||||
import {
|
||||
createMobileVaultConfigFromMetadata,
|
||||
defaultMobileVaultMetadata,
|
||||
type MobileVaultMetadata,
|
||||
} from './mobileVaultMetadata'
|
||||
import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
|
||||
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
|
||||
import { seedMobileVaultIfEmpty } from './mobileVaultSeed'
|
||||
import type { MobileVaultFile } from './mobileVaultStorage'
|
||||
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
|
||||
|
||||
export async function loadDemoVaultNotes(vaultMetadata = defaultMobileVaultMetadata) {
|
||||
const storage = createNativeMobileVaultStorage()
|
||||
const demoVault = createDemoVaultConfig(vaultMetadata)
|
||||
if (shouldSeedDemoVault(vaultMetadata)) {
|
||||
await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault })
|
||||
await addMissingDemoVaultFiles({ files: demoVaultFiles(), storage, vault: demoVault })
|
||||
}
|
||||
|
||||
return createStoredMobileVaultRepository({ storage, vault: demoVault }).listNotes()
|
||||
}
|
||||
|
||||
export function saveDemoVaultDraft(draft: MobileEditorDraft, vaultMetadata = defaultMobileVaultMetadata) {
|
||||
return saveMobileEditorDraft({
|
||||
draft,
|
||||
storage: createNativeMobileVaultStorage(),
|
||||
vault: createDemoVaultConfig(vaultMetadata),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createDemoVaultNote({
|
||||
title,
|
||||
vaultMetadata = defaultMobileVaultMetadata,
|
||||
}: {
|
||||
title?: string
|
||||
vaultMetadata?: MobileVaultMetadata
|
||||
} = {}) {
|
||||
const storage = createNativeMobileVaultStorage()
|
||||
const demoVault = createDemoVaultConfig(vaultMetadata)
|
||||
const file = createMobileNoteFile({ title })
|
||||
await storage.writeMarkdownFile(demoVault, file.path, file.content)
|
||||
|
||||
return createStoredMobileVaultRepository({ storage, vault: demoVault }).readNote(file.path.replace(/\.md$/, ''))
|
||||
}
|
||||
|
||||
export async function deleteDemoVaultNote(noteId: string, vaultMetadata = defaultMobileVaultMetadata) {
|
||||
const storage = createNativeMobileVaultStorage()
|
||||
await createStoredMobileVaultRepository({
|
||||
storage,
|
||||
vault: createDemoVaultConfig(vaultMetadata),
|
||||
}).deleteNote(noteId)
|
||||
}
|
||||
|
||||
export function saveDemoVaultNoteFrontmatter({
|
||||
metadata,
|
||||
noteId,
|
||||
vaultMetadata = defaultMobileVaultMetadata,
|
||||
}: {
|
||||
metadata: WritableMobileNoteFrontmatter
|
||||
noteId: string
|
||||
vaultMetadata?: MobileVaultMetadata
|
||||
}) {
|
||||
return saveDemoVaultDocumentChange({
|
||||
vaultMetadata,
|
||||
write: ({ storage, vault }) => saveMobileNoteFrontmatter({ metadata, noteId, storage, vault }),
|
||||
})
|
||||
}
|
||||
|
||||
function demoVaultFiles(): MobileVaultFile[] {
|
||||
return demoNoteSources.map((source) => ({
|
||||
path: source.filename,
|
||||
content: source.content,
|
||||
}))
|
||||
}
|
||||
|
||||
function createDemoVaultConfig(vaultMetadata: MobileVaultMetadata) {
|
||||
return createMobileVaultConfigFromMetadata(vaultMetadata)
|
||||
}
|
||||
|
||||
|
||||
function createDemoVaultStorageContext(vaultMetadata: MobileVaultMetadata) {
|
||||
return {
|
||||
storage: createNativeMobileVaultStorage(),
|
||||
vault: createDemoVaultConfig(vaultMetadata),
|
||||
}
|
||||
}
|
||||
|
||||
function saveDemoVaultDocumentChange<T>({
|
||||
vaultMetadata,
|
||||
write,
|
||||
}: {
|
||||
vaultMetadata: MobileVaultMetadata
|
||||
write: (context: ReturnType<typeof createDemoVaultStorageContext>) => T
|
||||
}) {
|
||||
return write(createDemoVaultStorageContext(vaultMetadata))
|
||||
}
|
||||
|
||||
async function addMissingDemoVaultFiles({
|
||||
files,
|
||||
storage,
|
||||
vault,
|
||||
}: {
|
||||
files: MobileVaultFile[]
|
||||
storage: ReturnType<typeof createNativeMobileVaultStorage>
|
||||
vault: ReturnType<typeof createDemoVaultConfig>
|
||||
}) {
|
||||
const existingPaths = new Set((await storage.listMarkdownFiles(vault)).map((file) => file.path))
|
||||
const missingFiles = files.filter((file) => !existingPaths.has(file.path))
|
||||
await Promise.all(missingFiles.map((file) => storage.writeMarkdownFile(vault, file.path, file.content)))
|
||||
}
|
||||
20
apps/mobile/src/mobileDemoVaultRawNote.ts
Normal file
20
apps/mobile/src/mobileDemoVaultRawNote.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
|
||||
import { saveMobileRawNote } from './mobileRawNoteSave'
|
||||
import { createMobileVaultConfigFromMetadata, defaultMobileVaultMetadata, type MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
|
||||
export function saveDemoVaultRawNote({
|
||||
content,
|
||||
noteId,
|
||||
vaultMetadata = defaultMobileVaultMetadata,
|
||||
}: {
|
||||
content: string
|
||||
noteId: string
|
||||
vaultMetadata?: MobileVaultMetadata
|
||||
}) {
|
||||
return saveMobileRawNote({
|
||||
content,
|
||||
noteId,
|
||||
storage: createNativeMobileVaultStorage(),
|
||||
vault: createMobileVaultConfigFromMetadata(vaultMetadata),
|
||||
})
|
||||
}
|
||||
5
apps/mobile/src/mobileDemoVaultSeedPolicy.ts
Normal file
5
apps/mobile/src/mobileDemoVaultSeedPolicy.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
|
||||
export function shouldSeedDemoVault(vaultMetadata: MobileVaultMetadata) {
|
||||
return !vaultMetadata.remoteUrl?.trim()
|
||||
}
|
||||
71
apps/mobile/src/mobileDerivedRelationships.test.ts
Normal file
71
apps/mobile/src/mobileDerivedRelationships.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileDerivedRelationshipGroups } from './mobileDerivedRelationships'
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
|
||||
describe('mobile derived relationships', () => {
|
||||
it('derives read-only inverse relationship groups from other notes', () => {
|
||||
const current = note({ id: 'project/tolaria', title: 'Tolaria' })
|
||||
const child = note({ belongsTo: ['[[project/tolaria|Tolaria]]'], id: 'essay', title: 'Essay' })
|
||||
const related = note({ id: 'release', relatedTo: ['Tolaria'], title: 'Release' })
|
||||
const custom = note({ id: 'owner', relationships: { owner: ['[[project/tolaria|Tolaria]]'] }, title: 'Owner' })
|
||||
|
||||
expect(mobileDerivedRelationshipGroups({ note: current, notes: [current, child, related, custom] })).toEqual([
|
||||
{ label: 'Contains', targets: ['[[essay|Essay]]'] },
|
||||
{ label: 'Related from', targets: ['[[release|Release]]'] },
|
||||
{ label: 'Owner from', targets: ['[[owner|Owner]]'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates inverse targets inside a group', () => {
|
||||
const current = note({ id: 'project/tolaria', title: 'Tolaria' })
|
||||
const source = note({
|
||||
belongsTo: ['[[project/tolaria|Tolaria]]', 'Tolaria'],
|
||||
id: 'essay',
|
||||
title: 'Essay',
|
||||
})
|
||||
|
||||
expect(mobileDerivedRelationshipGroups({ note: current, notes: [current, source] })).toEqual([
|
||||
{ label: 'Contains', targets: ['[[essay|Essay]]'] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function note({
|
||||
belongsTo = [],
|
||||
has = [],
|
||||
id,
|
||||
relatedTo = [],
|
||||
relationships = {},
|
||||
title,
|
||||
}: {
|
||||
belongsTo?: string[]
|
||||
has?: string[]
|
||||
id: string
|
||||
relatedTo?: string[]
|
||||
relationships?: Record<string, string[]>
|
||||
title: string
|
||||
}): MobileNote {
|
||||
return {
|
||||
archived: false,
|
||||
backlinks: [],
|
||||
belongsTo,
|
||||
content: `# ${title}`,
|
||||
customProperties: {},
|
||||
date: '',
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
has,
|
||||
icon: 'file-text',
|
||||
id,
|
||||
modified: '',
|
||||
outgoingLinks: [],
|
||||
relatedTo,
|
||||
relationships,
|
||||
snippet: '',
|
||||
status: undefined,
|
||||
tags: [],
|
||||
title,
|
||||
type: 'Note',
|
||||
words: 1,
|
||||
}
|
||||
}
|
||||
68
apps/mobile/src/mobileDerivedRelationships.ts
Normal file
68
apps/mobile/src/mobileDerivedRelationships.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { MobileNote } from './mobileNoteProjection'
|
||||
import { hasMobileRelationshipRef, mobileWikilinkForNote, uniqueMobileRelationshipRefs } from './mobileRelationshipRefs'
|
||||
|
||||
export type MobileDerivedRelationshipGroup = {
|
||||
label: string
|
||||
targets: string[]
|
||||
}
|
||||
|
||||
export function mobileDerivedRelationshipGroups({
|
||||
note,
|
||||
notes,
|
||||
}: {
|
||||
note: MobileNote
|
||||
notes: MobileNote[]
|
||||
}) {
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const source of notes) {
|
||||
if (source.id !== note.id) {
|
||||
appendDerivedGroups({ groups, note, source })
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([label, targets]) => ({ label, targets: uniqueMobileRelationshipRefs(targets) }))
|
||||
.filter((group) => group.targets.length > 0)
|
||||
}
|
||||
|
||||
function appendDerivedGroups({
|
||||
groups,
|
||||
note,
|
||||
source,
|
||||
}: {
|
||||
groups: Map<string, string[]>
|
||||
note: MobileNote
|
||||
source: MobileNote
|
||||
}) {
|
||||
appendIfLinked({ groups, label: 'Contains', note, source, values: source.belongsTo })
|
||||
appendIfLinked({ groups, label: 'Related from', note, source, values: source.relatedTo })
|
||||
appendIfLinked({ groups, label: 'Part of', note, source, values: source.has })
|
||||
for (const [key, values] of Object.entries(source.relationships)) {
|
||||
appendIfLinked({ groups, label: `${formatRelationshipLabel(key)} from`, note, source, values })
|
||||
}
|
||||
}
|
||||
|
||||
function appendIfLinked({
|
||||
groups,
|
||||
label,
|
||||
note,
|
||||
source,
|
||||
values,
|
||||
}: {
|
||||
groups: Map<string, string[]>
|
||||
label: string
|
||||
note: MobileNote
|
||||
source: MobileNote
|
||||
values: string[]
|
||||
}) {
|
||||
if (
|
||||
hasMobileRelationshipRef({ target: mobileWikilinkForNote(note), values })
|
||||
|| hasMobileRelationshipRef({ target: note.title, values })
|
||||
) {
|
||||
groups.set(label, [...groups.get(label) ?? [], mobileWikilinkForNote(source)])
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelationshipLabel(key: string) {
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
}
|
||||
114
apps/mobile/src/mobileEditorDocument.test.ts
Normal file
114
apps/mobile/src/mobileEditorDocument.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMobileEditorDocument, createMobileEditorHtml } from './mobileEditorDocument'
|
||||
|
||||
describe('mobile editor document', () => {
|
||||
it('strips frontmatter and title heading from the displayed editor body', () => {
|
||||
expect(
|
||||
createMobileEditorDocument({
|
||||
id: 'workflow',
|
||||
title: 'Workflow Orchestration Essay',
|
||||
content: [
|
||||
'---',
|
||||
'type: Essay',
|
||||
'---',
|
||||
'',
|
||||
'# Workflow Orchestration Essay',
|
||||
'',
|
||||
'The current narrative: everything routed through an LLM.',
|
||||
].join('\n'),
|
||||
}),
|
||||
).toEqual({
|
||||
leadingTitle: 'Workflow Orchestration Essay',
|
||||
blocks: [
|
||||
{
|
||||
id: '0:The current narrative: everything routed through an LLM.',
|
||||
kind: 'paragraph',
|
||||
text: 'The current narrative: everything routed through an LLM.',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps colon paragraphs instead of treating them as frontmatter', () => {
|
||||
const document = createMobileEditorDocument({
|
||||
id: 'monday',
|
||||
title: 'Notes for Monday',
|
||||
content: '# Notes for Monday\n\nBottom line up front: ship the smallest useful slice.',
|
||||
})
|
||||
|
||||
expect(document.blocks).toEqual([
|
||||
{
|
||||
id: '0:Bottom line up front: ship the smallest useful slice.',
|
||||
kind: 'paragraph',
|
||||
text: 'Bottom line up front: ship the smallest useful slice.',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes markdown bullets for the native placeholder surface', () => {
|
||||
const document = createMobileEditorDocument({
|
||||
id: 'plan',
|
||||
title: 'Plan',
|
||||
content: '# Plan\n\n- Sidebar\n* Note list',
|
||||
})
|
||||
|
||||
expect(document.blocks).toEqual([
|
||||
{
|
||||
id: '0:- Sidebar',
|
||||
kind: 'bullet',
|
||||
text: 'Sidebar',
|
||||
},
|
||||
{
|
||||
id: '1:* Note list',
|
||||
kind: 'bullet',
|
||||
text: 'Note list',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('creates escaped HTML for TenTap initial content', () => {
|
||||
const html = createMobileEditorHtml({
|
||||
leadingTitle: 'Tolaria <mobile>',
|
||||
blocks: [
|
||||
{
|
||||
id: '0:Use TenTap',
|
||||
kind: 'paragraph',
|
||||
text: 'Use TenTap & keep markdown durable',
|
||||
},
|
||||
{
|
||||
id: '1:- Escape quotes',
|
||||
kind: 'bullet',
|
||||
text: 'Escape "quotes"',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(html).toBe(
|
||||
'<h1>Tolaria <mobile></h1><p>Use TenTap & keep markdown durable</p><ul><li>Escape "quotes"</li></ul>',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reinsert an H1 after the note body no longer starts with the title heading', () => {
|
||||
const document = createMobileEditorDocument({
|
||||
id: 'untitled',
|
||||
title: 'Untitled',
|
||||
content: 'Body without a leading heading',
|
||||
})
|
||||
|
||||
expect(document.leadingTitle).toBeNull()
|
||||
expect(createMobileEditorHtml(document)).toBe('<p>Body without a leading heading</p>')
|
||||
})
|
||||
|
||||
it('renders wikilinks as clickable rich links in the editor HTML', () => {
|
||||
const document = createMobileEditorDocument({
|
||||
id: 'links',
|
||||
title: 'Links',
|
||||
content: '# Links\n\nSee [[mobile-roadmap|Mobile Roadmap]] next.',
|
||||
})
|
||||
|
||||
expect(createMobileEditorHtml(document)).toContain(
|
||||
'<a data-tolaria-wikilink="true" href="tolaria-note:mobile-roadmap">Mobile Roadmap</a>',
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
89
apps/mobile/src/mobileEditorDocument.ts
Normal file
89
apps/mobile/src/mobileEditorDocument.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { splitFrontmatter } from '@tolaria/markdown'
|
||||
|
||||
export type MobileEditorBlock = {
|
||||
id: string
|
||||
kind: 'bullet' | 'paragraph'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type MobileEditorDocument = {
|
||||
leadingTitle: string | null
|
||||
blocks: MobileEditorBlock[]
|
||||
}
|
||||
|
||||
export type MobileEditorDocumentInput = {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export function createMobileEditorDocument(input: MobileEditorDocumentInput): MobileEditorDocument {
|
||||
const [, body] = splitFrontmatter(input.content)
|
||||
|
||||
return {
|
||||
leadingTitle: leadingTitle({ body, title: input.title }),
|
||||
blocks: createBlocks({ body, title: input.title }),
|
||||
}
|
||||
}
|
||||
|
||||
export function createMobileEditorHtml(document: MobileEditorDocument) {
|
||||
return `${titleHtml(document.leadingTitle)}${document.blocks.map(blockToHtml).join('') || '<p></p>'}`
|
||||
}
|
||||
|
||||
function leadingTitle({ body, title }: { body: string; title: string }) {
|
||||
const firstLine = body.split('\n').find((line) => line.trim().length > 0)?.trim()
|
||||
return firstLine && isTitleHeading({ line: firstLine, title }) ? title : null
|
||||
}
|
||||
|
||||
function createBlocks({ body, title }: { body: string; title: string }) {
|
||||
return body
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !isTitleHeading({ line, title }))
|
||||
.map((line, index) => createBlock({ index, line }))
|
||||
}
|
||||
|
||||
function createBlock({ index, line }: { index: number; line: string }): MobileEditorBlock {
|
||||
const bulletText = bulletContent({ line })
|
||||
|
||||
return {
|
||||
id: `${index}:${line}`,
|
||||
kind: bulletText ? 'bullet' : 'paragraph',
|
||||
text: bulletText ?? line,
|
||||
}
|
||||
}
|
||||
|
||||
function bulletContent({ line }: { line: string }) {
|
||||
const match = /^[-*]\s+(.+)$/.exec(line)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function isTitleHeading({ line, title }: { line: string; title: string }) {
|
||||
return line === `# ${title}`
|
||||
}
|
||||
|
||||
function blockToHtml(block: MobileEditorBlock) {
|
||||
const text = inlineTextToHtml(block.text)
|
||||
return block.kind === 'bullet' ? `<ul><li>${text}</li></ul>` : `<p>${text}</p>`
|
||||
}
|
||||
|
||||
function titleHtml(title: string | null) {
|
||||
return title ? `<h1>${escapeHtml({ value: title })}</h1>` : ''
|
||||
}
|
||||
|
||||
function inlineTextToHtml(value: string) {
|
||||
return escapeHtml({ value }).replace(/\[\[([^[\]]+?)\]\]/g, (_match, inner: string) => {
|
||||
const [target, alias] = inner.split('|')
|
||||
const label = alias?.trim() || target.trim()
|
||||
return `<a data-tolaria-wikilink="true" href="tolaria-note:${encodeURIComponent(target.trim())}">${label}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml({ value }: { value: string }) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
305
apps/mobile/src/mobileEditorDraft.test.ts
Normal file
305
apps/mobile/src/mobileEditorDraft.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMobileEditorDraft } from './mobileEditorDraft'
|
||||
|
||||
describe('mobile editor draft', () => {
|
||||
it('serializes supported TenTap HTML into canonical Markdown', () => {
|
||||
expect(
|
||||
createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'workflow',
|
||||
title: 'Workflow',
|
||||
content: '# Workflow\n\nOriginal markdown',
|
||||
},
|
||||
editorHtml: '<h1>Workflow</h1><p>Edited content</p><ul><li>First</li><li>Second</li></ul>',
|
||||
}),
|
||||
).toEqual({
|
||||
noteId: 'workflow',
|
||||
sourceMarkdown: '# Workflow\n\nOriginal markdown',
|
||||
editorHtml: '<h1>Workflow</h1><p>Edited content</p><ul><li>First</li><li>Second</li></ul>',
|
||||
persistable: true,
|
||||
canonicalMarkdown: '# Workflow\n\nEdited content\n\n- First\n- Second',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves source frontmatter outside the edited body', () => {
|
||||
const draft = createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'workflow',
|
||||
title: 'Workflow',
|
||||
content: '---\ntype: Essay\n---\n\n# Workflow\n\nOriginal markdown',
|
||||
},
|
||||
editorHtml: '<h1>Workflow</h1><p>Edited content</p>',
|
||||
})
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
persistable: true,
|
||||
canonicalMarkdown: '---\ntype: Essay\n---\n# Workflow\n\nEdited content',
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes escaped text before writing Markdown', () => {
|
||||
const draft = createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'symbols',
|
||||
title: 'Symbols',
|
||||
content: '# Symbols',
|
||||
},
|
||||
editorHtml: '<h1>Symbols</h1><p>Use <tags> & "quotes", !? and non breaking space</p>',
|
||||
})
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
persistable: true,
|
||||
canonicalMarkdown: '# Symbols\n\nUse <tags> & "quotes", !? and non breaking space',
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes headings, ordered lists, and inline marks', () => {
|
||||
const draft = createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'formatting',
|
||||
title: 'Formatting',
|
||||
content: '# Formatting',
|
||||
},
|
||||
editorHtml: [
|
||||
'<h2>Section</h2>',
|
||||
'<p>Use <strong>bold</strong>, <em>emphasis</em>, <code>code</code>, and <a href="https://tolaria.app">links</a>.</p>',
|
||||
'<ol><li>First</li><li>Second</li></ol>',
|
||||
].join(''),
|
||||
})
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
persistable: true,
|
||||
canonicalMarkdown: [
|
||||
'## Section',
|
||||
'',
|
||||
'Use **bold**, *emphasis*, `code`, and [links](https://tolaria.app).',
|
||||
'',
|
||||
'1. First',
|
||||
'1. Second',
|
||||
].join('\n'),
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes safe link destinations and decodes escaped link URLs', () => {
|
||||
const draft = createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'links',
|
||||
title: 'Links',
|
||||
content: '# Links',
|
||||
},
|
||||
editorHtml: [
|
||||
'<p><a href="https://tolaria.app?ref=notes&device=ios">Website</a></p>',
|
||||
'<p><a href="mailto:hello@tolaria.app">Email</a></p>',
|
||||
'<p><a href="notes/workflow.md">Relative note</a></p>',
|
||||
].join(''),
|
||||
})
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
persistable: true,
|
||||
canonicalMarkdown: [
|
||||
'[Website](https://tolaria.app?ref=notes&device=ios)',
|
||||
'',
|
||||
'[Email](mailto:hello@tolaria.app)',
|
||||
'',
|
||||
'[Relative note](notes/workflow.md)',
|
||||
].join('\n'),
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes rich wikilinks back to desktop-compatible wikilink markdown', () => {
|
||||
const draft = createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'links',
|
||||
title: 'Links',
|
||||
content: '# Links',
|
||||
},
|
||||
editorHtml: '<p>See <a data-tolaria-wikilink="true" href="tolaria-note:mobile-roadmap">Mobile Roadmap</a>.</p>',
|
||||
})
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
persistable: true,
|
||||
canonicalMarkdown: 'See [[mobile-roadmap|Mobile Roadmap]].',
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes blockquotes, code blocks, and strikethrough', () => {
|
||||
const draft = createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'formatting',
|
||||
title: 'Formatting',
|
||||
content: '# Formatting',
|
||||
},
|
||||
editorHtml: [
|
||||
'<blockquote><p>Quoted idea</p><p>Second line</p></blockquote>',
|
||||
'<pre><code class="language-ts">const value = <string>input</code></pre>',
|
||||
'<p>Keep <s>stale</s> text visible.</p>',
|
||||
].join(''),
|
||||
})
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
persistable: true,
|
||||
canonicalMarkdown: [
|
||||
'> Quoted idea',
|
||||
'> Second line',
|
||||
'',
|
||||
'```ts',
|
||||
'const value = <string>input',
|
||||
'```',
|
||||
'',
|
||||
'Keep ~~stale~~ text visible.',
|
||||
].join('\n'),
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes horizontal rules from TenTap HTML', () => {
|
||||
const draft = createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'break',
|
||||
title: 'Break',
|
||||
content: '# Break',
|
||||
},
|
||||
editorHtml: '<p>Before</p><hr><p>After</p>',
|
||||
})
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
persistable: true,
|
||||
canonicalMarkdown: 'Before\n\n---\n\nAfter',
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes TenTap-style task list items', () => {
|
||||
const draft = createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'tasks',
|
||||
title: 'Tasks',
|
||||
content: '# Tasks',
|
||||
},
|
||||
editorHtml: [
|
||||
'<ul data-type="taskList">',
|
||||
'<li data-checked="true"><label><input type="checkbox" checked=""></label><div><p>Done</p></div></li>',
|
||||
'<li data-checked="false"><label><input type="checkbox"></label><div><p>Todo</p></div></li>',
|
||||
'</ul>',
|
||||
].join(''),
|
||||
})
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
persistable: true,
|
||||
canonicalMarkdown: '- [x] Done\n- [ ] Todo',
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks unsupported HTML instead of persisting unknown editor output', () => {
|
||||
expect(
|
||||
createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'workflow',
|
||||
title: 'Workflow',
|
||||
content: '# Workflow\n\nOriginal markdown',
|
||||
},
|
||||
editorHtml: '<figure><figcaption>Not yet supported</figcaption></figure>',
|
||||
}),
|
||||
).toEqual({
|
||||
noteId: 'workflow',
|
||||
sourceMarkdown: '# Workflow\n\nOriginal markdown',
|
||||
editorHtml: '<figure><figcaption>Not yet supported</figcaption></figure>',
|
||||
persistable: false,
|
||||
blockedReason: 'unsupportedEditorHtml',
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks unsafe link destinations instead of persisting risky Markdown', () => {
|
||||
expect(
|
||||
createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'links',
|
||||
title: 'Links',
|
||||
content: '# Links',
|
||||
},
|
||||
editorHtml: '<p><a href="javascript:alert(1)">Unsafe</a></p>',
|
||||
}),
|
||||
).toMatchObject({
|
||||
noteId: 'links',
|
||||
persistable: false,
|
||||
blockedReason: 'unsupportedEditorHtml',
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes simple TenTap tables as Markdown tables', () => {
|
||||
expect(
|
||||
createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'table',
|
||||
title: 'Table',
|
||||
content: '# Table',
|
||||
},
|
||||
editorHtml: [
|
||||
'<table><tbody>',
|
||||
'<tr><th><p>Name</p></th><th><p>Status</p></th></tr>',
|
||||
'<tr><td><p>Tolaria</p></td><td><p>Ready & synced</p></td></tr>',
|
||||
'<tr><td><p>Pipe</p></td><td><p>A | B</p></td></tr>',
|
||||
'</tbody></table>',
|
||||
].join(''),
|
||||
}),
|
||||
).toMatchObject({
|
||||
noteId: 'table',
|
||||
persistable: true,
|
||||
canonicalMarkdown: [
|
||||
'| Name | Status |',
|
||||
'| --- | --- |',
|
||||
'| Tolaria | Ready & synced |',
|
||||
'| Pipe | A \\| B |',
|
||||
].join('\n'),
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks malformed tables instead of guessing columns', () => {
|
||||
expect(
|
||||
createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'table',
|
||||
title: 'Table',
|
||||
content: '# Table',
|
||||
},
|
||||
editorHtml: '<table><tbody><tr><td>Name</td><td>Status</td></tr><tr><td>Tolaria</td></tr></tbody></table>',
|
||||
}),
|
||||
).toMatchObject({
|
||||
noteId: 'table',
|
||||
persistable: false,
|
||||
blockedReason: 'unsupportedEditorHtml',
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes safe image attachments inside supported blocks', () => {
|
||||
expect(
|
||||
createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'image',
|
||||
title: 'Image',
|
||||
content: '# Image',
|
||||
},
|
||||
editorHtml: '<p>Before</p><p><img src="attachments/sketch.png" alt="Interface sketch"></p><p>After</p>',
|
||||
}),
|
||||
).toMatchObject({
|
||||
noteId: 'image',
|
||||
persistable: true,
|
||||
canonicalMarkdown: 'Before\n\n\n\nAfter',
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks transient or unsafe image sources inside otherwise supported blocks', () => {
|
||||
expect(
|
||||
createMobileEditorDraft({
|
||||
note: {
|
||||
id: 'image',
|
||||
title: 'Image',
|
||||
content: '# Image',
|
||||
},
|
||||
editorHtml: '<p><img src="blob:https://tolaria.app/preview" alt="Attachment"></p>',
|
||||
}),
|
||||
).toMatchObject({
|
||||
noteId: 'image',
|
||||
persistable: false,
|
||||
blockedReason: 'unsupportedEditorHtml',
|
||||
})
|
||||
})
|
||||
})
|
||||
67
apps/mobile/src/mobileEditorDraft.ts
Normal file
67
apps/mobile/src/mobileEditorDraft.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { splitFrontmatter } from '@tolaria/markdown'
|
||||
import type { MobileEditorDocumentInput } from './mobileEditorDocument'
|
||||
import { serializeSupportedMobileEditorHtml } from './mobileEditorHtmlMarkdown'
|
||||
|
||||
export type MobileEditorDraft =
|
||||
| {
|
||||
noteId: string
|
||||
sourceMarkdown: string
|
||||
editorHtml: string
|
||||
persistable: true
|
||||
canonicalMarkdown: string
|
||||
}
|
||||
| {
|
||||
noteId: string
|
||||
sourceMarkdown: string
|
||||
editorHtml: string
|
||||
persistable: false
|
||||
blockedReason: 'unsupportedEditorHtml'
|
||||
}
|
||||
|
||||
export function createMobileEditorDraft({
|
||||
editorHtml,
|
||||
note,
|
||||
}: {
|
||||
editorHtml: string
|
||||
note: MobileEditorDocumentInput
|
||||
}): MobileEditorDraft {
|
||||
const markdownBody = serializeSupportedMobileEditorHtml({ editorHtml })
|
||||
if (!markdownBody) {
|
||||
return createBlockedDraft({ editorHtml, note })
|
||||
}
|
||||
|
||||
return {
|
||||
noteId: note.id,
|
||||
sourceMarkdown: note.content,
|
||||
editorHtml,
|
||||
persistable: true,
|
||||
canonicalMarkdown: withFrontmatter({ markdownBody, sourceMarkdown: note.content }),
|
||||
}
|
||||
}
|
||||
|
||||
function createBlockedDraft({
|
||||
editorHtml,
|
||||
note,
|
||||
}: {
|
||||
editorHtml: string
|
||||
note: MobileEditorDocumentInput
|
||||
}): MobileEditorDraft {
|
||||
return {
|
||||
noteId: note.id,
|
||||
sourceMarkdown: note.content,
|
||||
editorHtml,
|
||||
persistable: false,
|
||||
blockedReason: 'unsupportedEditorHtml',
|
||||
}
|
||||
}
|
||||
|
||||
function withFrontmatter({
|
||||
markdownBody,
|
||||
sourceMarkdown,
|
||||
}: {
|
||||
markdownBody: string
|
||||
sourceMarkdown: string
|
||||
}) {
|
||||
const [frontmatter] = splitFrontmatter(sourceMarkdown)
|
||||
return frontmatter ? `${frontmatter}${markdownBody}` : markdownBody
|
||||
}
|
||||
48
apps/mobile/src/mobileEditorDraftSave.test.ts
Normal file
48
apps/mobile/src/mobileEditorDraftSave.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMobileEditorDraft } from './mobileEditorDraft'
|
||||
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
|
||||
import { createMobileVaultConfig } from './mobileVaultConfig'
|
||||
import { createMemoryMobileVaultStorage } from './mobileVaultStorage'
|
||||
|
||||
const vault = createVault()
|
||||
|
||||
describe('mobile editor draft save', () => {
|
||||
it('writes persistable editor drafts as canonical Markdown', async () => {
|
||||
const storage = createMemoryMobileVaultStorage([])
|
||||
const draft = createMobileEditorDraft({
|
||||
note: { id: 'notes/workflow', title: 'Workflow', content: '# Workflow' },
|
||||
editorHtml: '<h1>Workflow</h1><p>Edited</p>',
|
||||
})
|
||||
|
||||
await expect(saveMobileEditorDraft({ draft, storage, vault })).resolves.toEqual({
|
||||
status: 'saved',
|
||||
path: 'notes/workflow.md',
|
||||
})
|
||||
|
||||
await expect(storage.readMarkdownFile(vault, 'notes/workflow.md')).resolves.toBe('# Workflow\n\nEdited')
|
||||
})
|
||||
|
||||
it('does not write blocked editor drafts', async () => {
|
||||
const storage = createMemoryMobileVaultStorage([])
|
||||
const draft = createMobileEditorDraft({
|
||||
note: { id: 'workflow', title: 'Workflow', content: '# Workflow' },
|
||||
editorHtml: '<figure><figcaption>Unsupported</figcaption></figure>',
|
||||
})
|
||||
|
||||
await expect(saveMobileEditorDraft({ draft, storage, vault })).resolves.toEqual({
|
||||
status: 'blocked',
|
||||
reason: 'unsupportedEditorHtml',
|
||||
})
|
||||
|
||||
await expect(storage.listMarkdownFiles(vault)).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function createVault() {
|
||||
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.config
|
||||
}
|
||||
36
apps/mobile/src/mobileEditorDraftSave.ts
Normal file
36
apps/mobile/src/mobileEditorDraftSave.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { MobileEditorDraft } from './mobileEditorDraft'
|
||||
import type { MobileVaultConfig } from './mobileVaultConfig'
|
||||
import type { MobileVaultStorageDriver } from './mobileVaultStorage'
|
||||
|
||||
export type MobileEditorDraftSaveResult =
|
||||
| {
|
||||
status: 'saved'
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
status: 'blocked'
|
||||
reason: 'unsupportedEditorHtml'
|
||||
}
|
||||
|
||||
export async function saveMobileEditorDraft({
|
||||
draft,
|
||||
storage,
|
||||
vault,
|
||||
}: {
|
||||
draft: MobileEditorDraft
|
||||
storage: MobileVaultStorageDriver
|
||||
vault: MobileVaultConfig
|
||||
}): Promise<MobileEditorDraftSaveResult> {
|
||||
if (!draft.persistable) {
|
||||
return { status: 'blocked', reason: draft.blockedReason }
|
||||
}
|
||||
|
||||
const path = draftPath(draft)
|
||||
await storage.writeMarkdownFile(vault, path, draft.canonicalMarkdown)
|
||||
|
||||
return { status: 'saved', path }
|
||||
}
|
||||
|
||||
function draftPath(draft: MobileEditorDraft) {
|
||||
return `${draft.noteId}.md`
|
||||
}
|
||||
315
apps/mobile/src/mobileEditorHtmlMarkdown.ts
Normal file
315
apps/mobile/src/mobileEditorHtmlMarkdown.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
canSerializeMobileEditorTable,
|
||||
isMobileEditorTableBlock,
|
||||
mobileEditorTableMarkdown,
|
||||
} from './mobileEditorTableMarkdown'
|
||||
import { decodeMobileHtmlEntities } from './mobileHtmlEntities'
|
||||
|
||||
type EditorHtmlInput = {
|
||||
editorHtml: string
|
||||
}
|
||||
|
||||
type HtmlInput = {
|
||||
html: string
|
||||
}
|
||||
|
||||
type ListItemInput = HtmlInput & {
|
||||
ordered: boolean
|
||||
}
|
||||
|
||||
const supportedHtmlTags = new Set([
|
||||
'a',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'code',
|
||||
'del',
|
||||
'div',
|
||||
'em',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'img',
|
||||
'input',
|
||||
'label',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
's',
|
||||
'strike',
|
||||
'strong',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
'ul',
|
||||
])
|
||||
|
||||
export function serializeSupportedMobileEditorHtml(input: EditorHtmlInput) {
|
||||
const html = normalizeBlockSpacing(input)
|
||||
const blocks = html.match(/<(h[1-6]|p|ul|ol|blockquote|pre|table)(?:\s[^>]*)?>[\s\S]*?<\/\1>|<hr(?:\s[^>]*)?\s*\/?>/gi)
|
||||
if (!blocks) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!canSerializeBlocks({ blocks, html })) {
|
||||
return null
|
||||
}
|
||||
|
||||
return blocks.map((block) => serializeBlock({ html: block })).join('\n\n')
|
||||
}
|
||||
|
||||
function canSerializeBlocks({
|
||||
blocks,
|
||||
html,
|
||||
}: {
|
||||
blocks: RegExpMatchArray
|
||||
html: string
|
||||
}) {
|
||||
return blocks.join('') === html && !blocks.some((block) => blocksUnsafeEditorOutput({ html: block }))
|
||||
}
|
||||
|
||||
function serializeBlock(input: HtmlInput) {
|
||||
const headingLevel = headingMarkdownLevel(input)
|
||||
if (headingLevel) {
|
||||
return `${'#'.repeat(headingLevel)} ${inlineMarkdown(input)}`
|
||||
}
|
||||
|
||||
if (isListBlock(input)) {
|
||||
return listItemMarkdown(input).join('\n')
|
||||
}
|
||||
|
||||
if (isBlockquote(input)) {
|
||||
return blockquoteMarkdown(input)
|
||||
}
|
||||
|
||||
if (isCodeBlock(input)) {
|
||||
return codeBlockMarkdown(input)
|
||||
}
|
||||
|
||||
if (isHorizontalRule(input)) {
|
||||
return '---'
|
||||
}
|
||||
|
||||
if (isMobileEditorTableBlock(input)) {
|
||||
return mobileEditorTableMarkdown(input)
|
||||
}
|
||||
|
||||
return inlineMarkdown(input)
|
||||
}
|
||||
|
||||
function normalizeBlockSpacing(input: EditorHtmlInput) {
|
||||
return input.editorHtml.trim().replace(/>\s+</g, '><')
|
||||
}
|
||||
|
||||
function headingMarkdownLevel(input: HtmlInput) {
|
||||
const match = input.html.match(/^<h([1-6])/i)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function isListBlock(input: HtmlInput) {
|
||||
return input.html.match(/^<(ul|ol)/i)
|
||||
}
|
||||
|
||||
function isBlockquote(input: HtmlInput) {
|
||||
return input.html.match(/^<blockquote/i)
|
||||
}
|
||||
|
||||
function isCodeBlock(input: HtmlInput) {
|
||||
return input.html.match(/^<pre/i)
|
||||
}
|
||||
|
||||
function isHorizontalRule(input: HtmlInput) {
|
||||
return input.html.match(/^<hr/i)
|
||||
}
|
||||
|
||||
function listItemMarkdown(input: HtmlInput) {
|
||||
const ordered = input.html.match(/^<ol/i)
|
||||
return [...input.html.matchAll(/<li(?:\s[^>]*)?>([\s\S]*?)<\/li>/gi)].map((match) =>
|
||||
formatListItem({ ordered: Boolean(ordered), html: match[0] }),
|
||||
)
|
||||
}
|
||||
|
||||
function formatListItem(input: ListItemInput) {
|
||||
const taskMarker = markdownTaskMarker(input)
|
||||
const prefix = taskMarker ? `- ${taskMarker}` : input.ordered ? '1.' : '-'
|
||||
|
||||
return `${prefix} ${inlineMarkdown(input)}`
|
||||
}
|
||||
|
||||
function markdownTaskMarker(input: HtmlInput) {
|
||||
if (input.html.match(/data-checked=["']true/i) || input.html.match(/<input[^>]+checked/i)) {
|
||||
return '[x]'
|
||||
}
|
||||
|
||||
if (input.html.match(/data-checked=["']false/i) || input.html.match(/<input[^>]+type=["']checkbox/i)) {
|
||||
return '[ ]'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function blockquoteMarkdown(input: HtmlInput) {
|
||||
const paragraphLines = [...input.html.matchAll(/<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/gi)].map((match) =>
|
||||
inlineMarkdown({ html: match[0] }),
|
||||
)
|
||||
const lines = paragraphLines.length > 0 ? paragraphLines : [inlineMarkdown(input)]
|
||||
|
||||
return lines.map((line) => `> ${line}`).join('\n')
|
||||
}
|
||||
|
||||
function codeBlockMarkdown(input: HtmlInput) {
|
||||
return [
|
||||
`\`\`\`${codeBlockLanguage(input)}`,
|
||||
decodeMobileHtmlEntities({ text: codeBlockText(input) }).trimEnd(),
|
||||
'```',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function codeBlockLanguage(input: HtmlInput) {
|
||||
return input.html.match(/language-([A-Za-z0-9_-]+)/)?.[1] ?? ''
|
||||
}
|
||||
|
||||
function codeBlockText(input: HtmlInput) {
|
||||
const code = input.html.match(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/i)?.[1] ?? input.html
|
||||
return stripRemainingTags(code.replace(/<br\s*\/?>/gi, '\n'))
|
||||
}
|
||||
|
||||
function inlineMarkdown(input: HtmlInput) {
|
||||
return decodeMobileHtmlEntities({ text: stripRemainingTags(markInlineHtml(input)).trim() })
|
||||
}
|
||||
|
||||
function markInlineHtml(input: HtmlInput) {
|
||||
return input.html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<img\b[^>]*>/gi, (tag) => imageMarkdown({ tag }) ?? tag)
|
||||
.replace(/<(strong|b)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '**$2**')
|
||||
.replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*')
|
||||
.replace(/<(s|strike|del)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '~~$2~~')
|
||||
.replace(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/gi, '`$1`')
|
||||
.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, (tag, label) => linkMarkdown({ tag, label }) ?? tag)
|
||||
}
|
||||
|
||||
function containsUnsupportedTag(input: HtmlInput) {
|
||||
return [...input.html.matchAll(/<\/?([a-z0-9-]+)/gi)]
|
||||
.some((match) => !supportedHtmlTags.has(match[1].toLowerCase()))
|
||||
}
|
||||
|
||||
function blocksUnsafeEditorOutput(input: HtmlInput) {
|
||||
return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeLink(input) || containsUnsafeTable(input)
|
||||
}
|
||||
|
||||
function containsUnsafeImage(input: HtmlInput) {
|
||||
return [...input.html.matchAll(/<img\b[^>]*>/gi)].some((match) => !imageMarkdown({ tag: match[0] }))
|
||||
}
|
||||
|
||||
function containsUnsafeLink(input: HtmlInput) {
|
||||
return [...input.html.matchAll(/<a\b[^>]*>/gi)].some((match) => !linkMarkdown({ tag: match[0], label: '' }))
|
||||
}
|
||||
|
||||
function containsUnsafeTable(input: HtmlInput) {
|
||||
return Boolean(isMobileEditorTableBlock(input)) && !canSerializeMobileEditorTable(input)
|
||||
}
|
||||
|
||||
function imageMarkdown(input: { tag: string }) {
|
||||
const src = imageSource(input)
|
||||
if (!src) {
|
||||
return null
|
||||
}
|
||||
|
||||
const alt = htmlAttribute({ tag: input.tag, name: 'alt' }) ?? ''
|
||||
return `})`
|
||||
}
|
||||
|
||||
function imageSource(input: { tag: string }) {
|
||||
const src = htmlAttribute({ tag: input.tag, name: 'src' })
|
||||
return src && isPersistableImageSource({ src }) ? src : null
|
||||
}
|
||||
|
||||
function linkMarkdown(input: { tag: string; label: string }) {
|
||||
const wikilink = wikilinkMarkdown(input)
|
||||
if (wikilink) {
|
||||
return wikilink
|
||||
}
|
||||
|
||||
const href = linkDestination(input)
|
||||
return href ? `[${input.label}](${href})` : null
|
||||
}
|
||||
|
||||
function wikilinkMarkdown(input: { tag: string; label: string }) {
|
||||
const href = htmlAttribute({ tag: input.tag, name: 'href' })
|
||||
if (!href?.startsWith('tolaria-note:')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const target = decodeURIComponent(href.slice('tolaria-note:'.length)).trim()
|
||||
const label = decodeMobileHtmlEntities({ text: input.label }).trim()
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
|
||||
return label && label !== target ? `[[${target}|${label}]]` : `[[${target}]]`
|
||||
}
|
||||
|
||||
function linkDestination(input: { tag: string }) {
|
||||
const href = htmlAttribute({ tag: input.tag, name: 'href' })
|
||||
return href && isPersistableLinkDestination({ href }) ? decodeMobileHtmlEntities({ text: href }) : null
|
||||
}
|
||||
|
||||
function htmlAttribute(input: { tag: string; name: string }) {
|
||||
const match = input.tag.match(new RegExp(`${input.name}=["']([^"']+)["']`, 'i'))
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function isPersistableImageSource(input: { src: string }) {
|
||||
if (input.src.match(/[\n\r]/)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isRemoteImageSource(input) || isRelativeImageSource(input)
|
||||
}
|
||||
|
||||
function isRemoteImageSource(input: { src: string }) {
|
||||
return input.src.startsWith('https://') || input.src.startsWith('http://')
|
||||
}
|
||||
|
||||
function isRelativeImageSource(input: { src: string }) {
|
||||
return !input.src.startsWith('/') && !input.src.startsWith('//') && !input.src.match(/^[A-Za-z][A-Za-z0-9+.-]*:/)
|
||||
}
|
||||
|
||||
function isPersistableLinkDestination(input: { href: string }) {
|
||||
const href = decodeMobileHtmlEntities({ text: input.href })
|
||||
if (href.match(/[\n\r]/)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isRemoteLinkDestination({ href }) || isMailLinkDestination({ href }) || isRelativeLinkDestination({ href }) || isWikilinkDestination({ href })
|
||||
}
|
||||
|
||||
function isRemoteLinkDestination(input: { href: string }) {
|
||||
return input.href.startsWith('https://') || input.href.startsWith('http://')
|
||||
}
|
||||
|
||||
function isMailLinkDestination(input: { href: string }) {
|
||||
return input.href.startsWith('mailto:')
|
||||
}
|
||||
|
||||
function isRelativeLinkDestination(input: { href: string }) {
|
||||
return !input.href.startsWith('/') && !input.href.startsWith('//') && !input.href.match(/^[A-Za-z][A-Za-z0-9+.-]*:/)
|
||||
}
|
||||
|
||||
function isWikilinkDestination(input: { href: string }) {
|
||||
return input.href.startsWith('tolaria-note:') && input.href.length > 'tolaria-note:'.length
|
||||
}
|
||||
|
||||
function stripRemainingTags(value: string) {
|
||||
return value.replace(/<[^>]+>/g, '')
|
||||
}
|
||||
53
apps/mobile/src/mobileEditorMessages.test.ts
Normal file
53
apps/mobile/src/mobileEditorMessages.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseEditorMessage } from './mobileEditorMessages'
|
||||
|
||||
describe('mobile editor messages', () => {
|
||||
it('parses empty wikilink queries with cursor geometry', () => {
|
||||
expect(parseEditorMessage(JSON.stringify({
|
||||
frame: { bottom: 124, left: 48 },
|
||||
query: '',
|
||||
type: 'wikilinkQuery',
|
||||
}))).toEqual({
|
||||
frame: { bottom: 124, left: 48 },
|
||||
query: '',
|
||||
type: 'wikilinkQuery',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores invalid wikilink query geometry', () => {
|
||||
expect(parseEditorMessage(JSON.stringify({
|
||||
frame: { bottom: '124', left: 48 },
|
||||
query: 'roadmap',
|
||||
type: 'wikilinkQuery',
|
||||
}))).toEqual({
|
||||
frame: null,
|
||||
query: 'roadmap',
|
||||
type: 'wikilinkQuery',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses hardware Tab list indentation messages', () => {
|
||||
expect(parseEditorMessage(JSON.stringify({
|
||||
direction: 'in',
|
||||
type: 'listIndent',
|
||||
}))).toEqual({
|
||||
direction: 'in',
|
||||
type: 'listIndent',
|
||||
})
|
||||
|
||||
expect(parseEditorMessage(JSON.stringify({
|
||||
direction: 'out',
|
||||
type: 'listIndent',
|
||||
}))).toEqual({
|
||||
direction: 'out',
|
||||
type: 'listIndent',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed list indentation messages', () => {
|
||||
expect(parseEditorMessage(JSON.stringify({
|
||||
direction: 'sideways',
|
||||
type: 'listIndent',
|
||||
}))).toBeNull()
|
||||
})
|
||||
})
|
||||
88
apps/mobile/src/mobileEditorMessages.ts
Normal file
88
apps/mobile/src/mobileEditorMessages.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type MobileEditorMessage =
|
||||
| { target: string; type: 'openWikilink' }
|
||||
| { command: 'fileNewNote'; type: 'shortcut' }
|
||||
| { direction: 'in' | 'out'; type: 'listIndent' }
|
||||
| { frame: MobileEditorWikilinkFrame | null; query: string | null; type: 'wikilinkQuery' }
|
||||
|
||||
export type MobileEditorWikilinkFrame = {
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export function parseEditorMessage(data: string): MobileEditorMessage | null {
|
||||
try {
|
||||
return normalizeEditorMessage(JSON.parse(data))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEditorMessage(value: unknown): MobileEditorMessage | null {
|
||||
if (!isMessageRecord(value)) {
|
||||
return null
|
||||
}
|
||||
if (isWikilinkQueryMessage(value)) {
|
||||
return { frame: wikilinkFrame(value.frame), query: value.query, type: 'wikilinkQuery' }
|
||||
}
|
||||
if (isListIndentMessage(value)) {
|
||||
return { direction: value.direction, type: 'listIndent' }
|
||||
}
|
||||
if (value.type === 'openWikilink' && typeof value.target === 'string') {
|
||||
return { target: value.target, type: 'openWikilink' }
|
||||
}
|
||||
if (value.type === 'shortcut' && value.command === 'fileNewNote') {
|
||||
return { command: 'fileNewNote', type: 'shortcut' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isMessageRecord(value: unknown): value is {
|
||||
command?: unknown
|
||||
direction?: unknown
|
||||
frame?: unknown
|
||||
query?: unknown
|
||||
target?: unknown
|
||||
type?: unknown
|
||||
} {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function isWikilinkQueryMessage(value: {
|
||||
frame?: unknown
|
||||
query?: unknown
|
||||
type?: unknown
|
||||
}): value is {
|
||||
frame?: unknown
|
||||
query: string | null
|
||||
type: 'wikilinkQuery'
|
||||
} {
|
||||
return value.type === 'wikilinkQuery'
|
||||
&& (typeof value.query === 'string' || value.query === null)
|
||||
}
|
||||
|
||||
function wikilinkFrame(value: unknown): MobileEditorWikilinkFrame | null {
|
||||
if (!isFrameRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { bottom: value.bottom, left: value.left }
|
||||
}
|
||||
|
||||
function isFrameRecord(value: unknown): value is MobileEditorWikilinkFrame {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& typeof (value as { bottom?: unknown }).bottom === 'number'
|
||||
&& typeof (value as { left?: unknown }).left === 'number'
|
||||
}
|
||||
|
||||
function isListIndentMessage(value: {
|
||||
direction?: unknown
|
||||
type?: unknown
|
||||
}): value is {
|
||||
direction: 'in' | 'out'
|
||||
type: 'listIndent'
|
||||
} {
|
||||
return value.type === 'listIndent'
|
||||
&& (value.direction === 'in' || value.direction === 'out')
|
||||
}
|
||||
28
apps/mobile/src/mobileEditorSaveState.test.ts
Normal file
28
apps/mobile/src/mobileEditorSaveState.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
failedMobileEditorSaveState,
|
||||
idleMobileEditorSaveState,
|
||||
queuedMobileEditorSaveState,
|
||||
saveResultState,
|
||||
savingMobileEditorSaveState,
|
||||
} from './mobileEditorSaveState'
|
||||
|
||||
describe('mobile editor save state', () => {
|
||||
it('provides stable labels for direct save states', () => {
|
||||
expect(idleMobileEditorSaveState.label).toBe('Ready')
|
||||
expect(queuedMobileEditorSaveState.label).toBe('Edited')
|
||||
expect(savingMobileEditorSaveState.label).toBe('Saving')
|
||||
expect(failedMobileEditorSaveState.label).toBe('Save failed')
|
||||
})
|
||||
|
||||
it('derives visible state from save results', () => {
|
||||
expect(saveResultState({ status: 'saved', path: 'workflow.md' })).toEqual({
|
||||
state: 'saved',
|
||||
label: 'Saved',
|
||||
})
|
||||
expect(saveResultState({ status: 'blocked', reason: 'unsupportedEditorHtml' })).toEqual({
|
||||
state: 'blocked',
|
||||
label: 'Blocked',
|
||||
})
|
||||
})
|
||||
})
|
||||
53
apps/mobile/src/mobileEditorSaveState.ts
Normal file
53
apps/mobile/src/mobileEditorSaveState.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { MobileEditorDraftSaveResult } from './mobileEditorDraftSave'
|
||||
|
||||
export type MobileEditorSaveState =
|
||||
| {
|
||||
state: 'idle'
|
||||
label: 'Ready'
|
||||
}
|
||||
| {
|
||||
state: 'queued'
|
||||
label: 'Edited'
|
||||
}
|
||||
| {
|
||||
state: 'saving'
|
||||
label: 'Saving'
|
||||
}
|
||||
| {
|
||||
state: 'saved'
|
||||
label: 'Saved'
|
||||
}
|
||||
| {
|
||||
state: 'blocked'
|
||||
label: 'Blocked'
|
||||
}
|
||||
| {
|
||||
state: 'failed'
|
||||
label: 'Save failed'
|
||||
}
|
||||
|
||||
export const idleMobileEditorSaveState: MobileEditorSaveState = {
|
||||
state: 'idle',
|
||||
label: 'Ready',
|
||||
}
|
||||
|
||||
export const queuedMobileEditorSaveState: MobileEditorSaveState = {
|
||||
state: 'queued',
|
||||
label: 'Edited',
|
||||
}
|
||||
|
||||
export const savingMobileEditorSaveState: MobileEditorSaveState = {
|
||||
state: 'saving',
|
||||
label: 'Saving',
|
||||
}
|
||||
|
||||
export const failedMobileEditorSaveState: MobileEditorSaveState = {
|
||||
state: 'failed',
|
||||
label: 'Save failed',
|
||||
}
|
||||
|
||||
export function saveResultState(result: MobileEditorDraftSaveResult): MobileEditorSaveState {
|
||||
return result.status === 'saved'
|
||||
? { state: 'saved', label: 'Saved' }
|
||||
: { state: 'blocked', label: 'Blocked' }
|
||||
}
|
||||
62
apps/mobile/src/mobileEditorTableMarkdown.ts
Normal file
62
apps/mobile/src/mobileEditorTableMarkdown.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { decodeMobileHtmlEntities } from './mobileHtmlEntities'
|
||||
|
||||
type HtmlInput = {
|
||||
html: string
|
||||
}
|
||||
|
||||
type TableRow = {
|
||||
cells: string[]
|
||||
}
|
||||
|
||||
export function isMobileEditorTableBlock(input: HtmlInput) {
|
||||
return input.html.match(/^<table/i)
|
||||
}
|
||||
|
||||
export function canSerializeMobileEditorTable(input: HtmlInput) {
|
||||
const rows = tableRows(input)
|
||||
const columnCount = rows[0]?.cells.length ?? 0
|
||||
|
||||
return columnCount > 0 && rows.every((row) => row.cells.length === columnCount)
|
||||
}
|
||||
|
||||
export function mobileEditorTableMarkdown(input: HtmlInput) {
|
||||
const rows = tableRows(input)
|
||||
const header = rows[0]?.cells ?? []
|
||||
const body = rows.slice(1).map(tableRowMarkdown)
|
||||
|
||||
return [
|
||||
tableRowMarkdown({ cells: header }),
|
||||
tableSeparator({ columnCount: header.length }),
|
||||
...body,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function tableRows(input: HtmlInput) {
|
||||
return [...input.html.matchAll(/<tr(?:\s[^>]*)?>([\s\S]*?)<\/tr>/gi)]
|
||||
.map((match) => tableRow({ html: match[1] }))
|
||||
}
|
||||
|
||||
function tableRow(input: HtmlInput): TableRow {
|
||||
return {
|
||||
cells: [...input.html.matchAll(/<t[hd](?:\s[^>]*)?>([\s\S]*?)<\/t[hd]>/gi)]
|
||||
.map((match) => tableCellMarkdown({ html: match[1] })),
|
||||
}
|
||||
}
|
||||
|
||||
function tableCellMarkdown(input: HtmlInput) {
|
||||
return decodeMobileHtmlEntities({ text: stripCellTags(input).trim() })
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\|/g, '\\|')
|
||||
}
|
||||
|
||||
function tableRowMarkdown(input: TableRow) {
|
||||
return `| ${input.cells.join(' | ')} |`
|
||||
}
|
||||
|
||||
function tableSeparator(input: { columnCount: number }) {
|
||||
return tableRowMarkdown({ cells: Array.from({ length: input.columnCount }, () => '---') })
|
||||
}
|
||||
|
||||
function stripCellTags(input: HtmlInput) {
|
||||
return input.html.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '')
|
||||
}
|
||||
150
apps/mobile/src/mobileEditorWebViewSetup.ts
Normal file
150
apps/mobile/src/mobileEditorWebViewSetup.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export const mobileEditorSetupScript = `
|
||||
document.documentElement.lang = navigator.language || "en";
|
||||
document.addEventListener("keydown", function(event) {
|
||||
if (isFileNewShortcut(event)) {
|
||||
event.preventDefault();
|
||||
postEditorMessage({ type: "shortcut", command: "fileNewNote" });
|
||||
return;
|
||||
}
|
||||
if (!isTabInsideEditor(event)) return;
|
||||
event.preventDefault();
|
||||
postEditorMessage({
|
||||
type: "listIndent",
|
||||
direction: event.shiftKey ? "out" : "in"
|
||||
});
|
||||
}, true);
|
||||
document.addEventListener("click", function(event) {
|
||||
var link = event.target && event.target.closest && event.target.closest("a[href^='tolaria-note:']");
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
postEditorMessage({
|
||||
type: "openWikilink",
|
||||
target: decodeURIComponent(String(link.getAttribute("href") || "").replace(/^tolaria-note:/, ""))
|
||||
});
|
||||
}, true);
|
||||
function isFileNewShortcut(event) {
|
||||
return (event.metaKey || event.ctrlKey)
|
||||
&& !event.altKey
|
||||
&& String(event.key).toLowerCase() === "n";
|
||||
}
|
||||
function isTabInsideEditor(event) {
|
||||
if (event.key !== "Tab") return false;
|
||||
var selection = window.getSelection();
|
||||
var node = selection && selection.anchorNode;
|
||||
return Boolean(node && containingEditor(node));
|
||||
}
|
||||
function containingEditor(node) {
|
||||
var editor = document.querySelector(".ProseMirror");
|
||||
var container = node.nodeType === 1 ? node : node.parentNode;
|
||||
return editor && container && editor.contains(container);
|
||||
}
|
||||
function activeWikilinkQuery() {
|
||||
var selection = window.getSelection();
|
||||
if (!isCollapsedTextSelection(selection)) return null;
|
||||
if (!containingEditor(selection.anchorNode)) return null;
|
||||
return wikilinkQueryBeforeCursor(selection);
|
||||
}
|
||||
function isCollapsedTextSelection(selection) {
|
||||
return Boolean(selection
|
||||
&& selection.anchorNode
|
||||
&& selection.anchorNode.nodeType === 3
|
||||
&& selection.rangeCount > 0
|
||||
&& selection.isCollapsed);
|
||||
}
|
||||
function wikilinkQueryBeforeCursor(selection) {
|
||||
var prefix = String(selection.anchorNode.textContent || "").slice(0, selection.anchorOffset);
|
||||
var start = prefix.lastIndexOf("[[");
|
||||
if (start < 0) return null;
|
||||
var query = cleanWikilinkQuery(prefix.slice(start + 2));
|
||||
if (query === null) return null;
|
||||
return {
|
||||
frame: wikilinkQueryFrame(selection),
|
||||
query: query
|
||||
};
|
||||
}
|
||||
function cleanWikilinkQuery(query) {
|
||||
return query.indexOf("]]") >= 0 || query.indexOf("\\n") >= 0 ? null : query;
|
||||
}
|
||||
function emitWikilinkQuery() {
|
||||
var activeQuery = activeWikilinkQuery();
|
||||
postEditorMessage({
|
||||
type: "wikilinkQuery",
|
||||
frame: activeQuery ? activeQuery.frame : null,
|
||||
query: activeQuery ? activeQuery.query : null
|
||||
});
|
||||
}
|
||||
function wikilinkQueryFrame(selection) {
|
||||
var range = selection.getRangeAt(0).cloneRange();
|
||||
var rect = range.getBoundingClientRect();
|
||||
if (hasVisibleFrame(rect)) {
|
||||
return {
|
||||
bottom: rect.bottom,
|
||||
left: rect.left
|
||||
};
|
||||
}
|
||||
return fallbackWikilinkQueryFrame(selection);
|
||||
}
|
||||
function fallbackWikilinkQueryFrame(selection) {
|
||||
var container = selection.anchorNode && selection.anchorNode.parentElement;
|
||||
var rect = container && container.getBoundingClientRect && container.getBoundingClientRect();
|
||||
return {
|
||||
bottom: rect ? rect.bottom : 0,
|
||||
left: rect ? rect.left : 0
|
||||
};
|
||||
}
|
||||
function hasVisibleFrame(rect) {
|
||||
if (!rect) return false;
|
||||
return Boolean(rect.bottom || rect.left);
|
||||
}
|
||||
function postEditorMessage(message) {
|
||||
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
|
||||
}
|
||||
document.addEventListener("keyup", emitWikilinkQuery, true);
|
||||
document.addEventListener("mouseup", emitWikilinkQuery, true);
|
||||
document.addEventListener("selectionchange", emitWikilinkQuery, true);
|
||||
true;
|
||||
`
|
||||
|
||||
export const mobileEditorCss = `
|
||||
* {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root,
|
||||
.ProseMirror {
|
||||
color: #292825;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ProseMirror h1 {
|
||||
font-family: inherit;
|
||||
font-size: 42px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.08;
|
||||
margin: 18px 0 28px;
|
||||
}
|
||||
|
||||
.ProseMirror p,
|
||||
.ProseMirror li,
|
||||
.ProseMirror blockquote {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ProseMirror a[href^="tolaria-note:"] {
|
||||
color: #3367f6;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
background: #e8eeff;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
`
|
||||
310
apps/mobile/src/mobileExpoGitFileSystem.ts
Normal file
310
apps/mobile/src/mobileExpoGitFileSystem.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import { createMobileVaultConfigFromMetadata } from './mobileVaultMetadata'
|
||||
import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
import type { ExpoMobileVaultFileInfo, ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
|
||||
import type { PromiseFsClient } from 'isomorphic-git'
|
||||
|
||||
type MobileGitStat = {
|
||||
ctime: Date
|
||||
ctimeMs: number
|
||||
dev: number
|
||||
gid: number
|
||||
ino: number
|
||||
isDirectory: () => boolean
|
||||
isFile: () => boolean
|
||||
isSymbolicLink: () => boolean
|
||||
mode: number
|
||||
mtime: Date
|
||||
mtimeMs: number
|
||||
size: number
|
||||
uid: number
|
||||
}
|
||||
|
||||
type MobileGitReadOptions = 'utf8' | { encoding?: 'base64' | 'utf8' }
|
||||
type MobileGitWriteOptions = { encoding?: 'base64' | 'utf8' }
|
||||
type MobileGitFileData = string | Uint8Array
|
||||
type ExistingPathInput = {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
path: string
|
||||
rootUri: string
|
||||
}
|
||||
|
||||
export type MobileExpoGitFileSystemContext = {
|
||||
dir: string
|
||||
fs: PromiseFsClient
|
||||
}
|
||||
|
||||
export function createMobileExpoGitFileSystem({
|
||||
fileSystem,
|
||||
vault,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
vault: MobileVaultMetadata
|
||||
}): MobileExpoGitFileSystemContext {
|
||||
const rootUri = mobileExpoGitVaultRootUri({ fileSystem, vault })
|
||||
const dir = '/vault'
|
||||
|
||||
return {
|
||||
dir,
|
||||
fs: {
|
||||
promises: {
|
||||
chmod: async () => {},
|
||||
lstat: (path: string) => statPath({ fileSystem, path, rootUri }),
|
||||
mkdir: (path: string) => mkdirPath({ fileSystem, path, rootUri }),
|
||||
readFile: (path: string, options?: MobileGitReadOptions) => readFile({ fileSystem, options, path, rootUri }),
|
||||
readlink: async () => {
|
||||
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
|
||||
},
|
||||
readdir: (path: string) => readDirectory({ fileSystem, path, rootUri }),
|
||||
rmdir: (path: string) => removeDirectory({ fileSystem, path, rootUri }),
|
||||
stat: (path: string) => statPath({ fileSystem, path, rootUri }),
|
||||
symlink: async () => {
|
||||
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
|
||||
},
|
||||
unlink: (path: string) => unlinkPath({ fileSystem, path, rootUri }),
|
||||
writeFile: (path: string, data: MobileGitFileData, options?: MobileGitWriteOptions) =>
|
||||
writeFile({ data, fileSystem, options, path, rootUri }),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function mobileExpoGitVaultRootUri({
|
||||
fileSystem,
|
||||
vault,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
vault: MobileVaultMetadata
|
||||
}) {
|
||||
if (!fileSystem.documentDirectory) {
|
||||
throw new Error('Expo FileSystem documentDirectory is unavailable')
|
||||
}
|
||||
|
||||
const vaultConfig = createMobileVaultConfigFromMetadata(vault)
|
||||
return appendUri({
|
||||
root: fileSystem.documentDirectory,
|
||||
segments: ['vaults', vaultConfig.storage.directoryName || vaultConfig.id],
|
||||
})
|
||||
}
|
||||
|
||||
async function mkdirPath({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
path: string
|
||||
rootUri: string
|
||||
}) {
|
||||
const uri = uriForPath({ path, rootUri })
|
||||
const info = await fileSystem.getInfoAsync(uri)
|
||||
if (info.exists && !info.isDirectory) {
|
||||
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${path}`)
|
||||
}
|
||||
|
||||
if (!info.exists) {
|
||||
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function readDirectory({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: ExistingPathInput) {
|
||||
await existingDirectory({ fileSystem, path, rootUri })
|
||||
|
||||
return fileSystem.readDirectoryAsync(uriForPath({ path, rootUri }))
|
||||
}
|
||||
|
||||
async function readFile({
|
||||
fileSystem,
|
||||
options,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
options?: MobileGitReadOptions
|
||||
path: string
|
||||
rootUri: string
|
||||
}) {
|
||||
const info = await existingInfo({ fileSystem, path, rootUri })
|
||||
if (info.isDirectory) {
|
||||
throw createFileSystemError('EISDIR', `Path is a directory: ${path}`)
|
||||
}
|
||||
|
||||
const uri = uriForPath({ path, rootUri })
|
||||
return readAsUtf8(options)
|
||||
? fileSystem.readAsStringAsync(uri, { encoding: 'utf8' })
|
||||
: Buffer.from(await fileSystem.readAsStringAsync(uri, { encoding: 'base64' }), 'base64')
|
||||
}
|
||||
|
||||
async function writeFile({
|
||||
data,
|
||||
fileSystem,
|
||||
options,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
data: MobileGitFileData
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
options?: MobileGitWriteOptions
|
||||
path: string
|
||||
rootUri: string
|
||||
}) {
|
||||
await ensureParentDirectory({ fileSystem, path, rootUri })
|
||||
|
||||
if (typeof data === 'string' && writeAsUtf8(options)) {
|
||||
await fileSystem.writeAsStringAsync(uriForPath({ path, rootUri }), data, { encoding: 'utf8' })
|
||||
return
|
||||
}
|
||||
|
||||
await fileSystem.writeAsStringAsync(
|
||||
uriForPath({ path, rootUri }),
|
||||
dataToBase64(data),
|
||||
{ encoding: 'base64' },
|
||||
)
|
||||
}
|
||||
|
||||
async function unlinkPath({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: ExistingPathInput) {
|
||||
await existingFile({ fileSystem, path, rootUri })
|
||||
|
||||
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
|
||||
}
|
||||
|
||||
async function removeDirectory({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: ExistingPathInput) {
|
||||
await existingDirectory({ fileSystem, path, rootUri })
|
||||
|
||||
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
|
||||
}
|
||||
|
||||
async function statPath({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
path: string
|
||||
rootUri: string
|
||||
}): Promise<MobileGitStat> {
|
||||
return statForInfo(await existingInfo({ fileSystem, path, rootUri }))
|
||||
}
|
||||
|
||||
async function existingInfo({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: ExistingPathInput) {
|
||||
const info = await fileSystem.getInfoAsync(uriForPath({ path, rootUri }))
|
||||
if (!info.exists) {
|
||||
throw createFileSystemError('ENOENT', `Path does not exist: ${path}`)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
async function existingDirectory(input: ExistingPathInput) {
|
||||
const info = await existingInfo(input)
|
||||
if (!info.isDirectory) {
|
||||
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${input.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function existingFile(input: ExistingPathInput) {
|
||||
const info = await existingInfo(input)
|
||||
if (info.isDirectory) {
|
||||
throw createFileSystemError('EISDIR', `Path is a directory: ${input.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function statForInfo(info: ExpoMobileVaultFileInfo): MobileGitStat {
|
||||
const modifiedMs = Math.round((info.modificationTime ?? 0) * 1000)
|
||||
const modified = new Date(modifiedMs)
|
||||
const isDirectory = info.isDirectory === true
|
||||
|
||||
return {
|
||||
ctime: modified,
|
||||
ctimeMs: modifiedMs,
|
||||
dev: 0,
|
||||
gid: 0,
|
||||
ino: 0,
|
||||
isDirectory: () => isDirectory,
|
||||
isFile: () => !isDirectory,
|
||||
isSymbolicLink: () => false,
|
||||
mode: isDirectory ? 0o040000 : 0o100644,
|
||||
mtime: modified,
|
||||
mtimeMs: modifiedMs,
|
||||
size: info.size ?? 0,
|
||||
uid: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureParentDirectory({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
path: string
|
||||
rootUri: string
|
||||
}) {
|
||||
const segments = normalizedSegments(path)
|
||||
const parentSegments = segments.slice(0, -1)
|
||||
if (parentSegments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await fileSystem.makeDirectoryAsync(appendUri({ root: rootUri, segments: parentSegments }), { intermediates: true })
|
||||
}
|
||||
|
||||
function uriForPath({ path, rootUri }: { path: string; rootUri: string }) {
|
||||
return appendUri({ root: rootUri, segments: normalizedSegments(path) })
|
||||
}
|
||||
|
||||
function normalizedSegments(path: string) {
|
||||
const pathWithoutRoot = path.replace(/^\/+vault\/?/, '')
|
||||
const segments = pathWithoutRoot.replaceAll('\\', '/').split('/').filter(Boolean)
|
||||
if (segments.some(isUnsafeSegment)) {
|
||||
throw createFileSystemError('EINVAL', `Unsafe mobile Git path: ${path}`)
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function isUnsafeSegment(segment: string) {
|
||||
return segment === '.' || segment === '..' || segment.includes('/')
|
||||
}
|
||||
|
||||
function readAsUtf8(options?: MobileGitReadOptions) {
|
||||
return options === 'utf8' || (typeof options === 'object' && options.encoding === 'utf8')
|
||||
}
|
||||
|
||||
function writeAsUtf8(options?: MobileGitWriteOptions) {
|
||||
return options?.encoding === 'utf8'
|
||||
}
|
||||
|
||||
function dataToBase64(data: MobileGitFileData) {
|
||||
return typeof data === 'string'
|
||||
? Buffer.from(data, 'utf8').toString('base64')
|
||||
: Buffer.from(data).toString('base64')
|
||||
}
|
||||
|
||||
function appendUri(input: { root: string; segments: string[] }) {
|
||||
const base = input.root.replace(/\/+$/, '')
|
||||
const path = input.segments.filter(Boolean).join('/')
|
||||
|
||||
return path ? `${base}/${path}` : base
|
||||
}
|
||||
|
||||
function createFileSystemError(code: string, message: string) {
|
||||
const error = new Error(message)
|
||||
return Object.assign(error, { code })
|
||||
}
|
||||
6
apps/mobile/src/mobileExpoNativeGitModule.ts
Normal file
6
apps/mobile/src/mobileExpoNativeGitModule.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { requireOptionalNativeModule } from 'expo-modules-core'
|
||||
import { loadMobileGitNativeModule } from './mobileNativeGitModule'
|
||||
|
||||
export function loadExpoMobileGitNativeModule() {
|
||||
return loadMobileGitNativeModule({ loadModule: requireOptionalNativeModule })
|
||||
}
|
||||
124
apps/mobile/src/mobileExpoVaultStorage.test.ts
Normal file
124
apps/mobile/src/mobileExpoVaultStorage.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createExpoMobileVaultStorage, type ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
|
||||
import { createMobileVaultConfig } from './mobileVaultConfig'
|
||||
|
||||
const vault = createVault()
|
||||
|
||||
describe('Expo mobile vault storage', () => {
|
||||
it('lists nested markdown files from the app-local vault directory', async () => {
|
||||
const fileSystem = createFakeFileSystem({
|
||||
'file:///documents/vaults/personal-journal/zeta.md': '# Zeta',
|
||||
'file:///documents/vaults/personal-journal/notes/alpha.md': '# Alpha',
|
||||
'file:///documents/vaults/personal-journal/asset.png': 'binary',
|
||||
})
|
||||
|
||||
await expect(createExpoMobileVaultStorage(fileSystem).listMarkdownFiles(vault)).resolves.toEqual([
|
||||
{ path: 'notes/alpha.md', content: '# Alpha' },
|
||||
{ path: 'zeta.md', content: '# Zeta' },
|
||||
])
|
||||
})
|
||||
|
||||
it('creates parent directories before writing markdown files', async () => {
|
||||
const fileSystem = createFakeFileSystem({})
|
||||
const storage = createExpoMobileVaultStorage(fileSystem)
|
||||
|
||||
await storage.writeMarkdownFile(vault, 'notes/new.md', '# New')
|
||||
|
||||
await expect(storage.readMarkdownFile(vault, 'notes/new.md')).resolves.toBe('# New')
|
||||
})
|
||||
|
||||
it('deletes markdown files idempotently from the app-local vault directory', async () => {
|
||||
const storage = createExpoMobileVaultStorage(createFakeFileSystem({
|
||||
'file:///documents/vaults/personal-journal/notes/new.md': '# New',
|
||||
}))
|
||||
|
||||
await storage.deleteMarkdownFile(vault, 'notes/new.md')
|
||||
await storage.deleteMarkdownFile(vault, 'notes/new.md')
|
||||
|
||||
await expect(storage.readMarkdownFile(vault, 'notes/new.md')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
|
||||
it('rejects paths outside the app-local vault directory', async () => {
|
||||
const storage = createExpoMobileVaultStorage(createFakeFileSystem({}))
|
||||
|
||||
await expect(storage.writeMarkdownFile(vault, '../outside.md', '# Nope')).rejects.toThrow(
|
||||
'Unsafe mobile vault path',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function createVault() {
|
||||
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.config
|
||||
}
|
||||
|
||||
function createFakeFileSystem(files: Record<string, string>): ExpoMobileVaultFileSystem {
|
||||
const fileByUri = new Map(Object.entries(files))
|
||||
const directoryUris = new Set(['file:///documents'])
|
||||
for (const uri of fileByUri.keys()) {
|
||||
addParentDirectories(directoryUris, uri)
|
||||
}
|
||||
|
||||
return {
|
||||
deleteAsync: async (uri) => {
|
||||
fileByUri.delete(uri)
|
||||
},
|
||||
documentDirectory: 'file:///documents',
|
||||
getInfoAsync: async (uri) => ({
|
||||
exists: fileByUri.has(uri) || directoryUris.has(uri),
|
||||
isDirectory: directoryUris.has(uri),
|
||||
}),
|
||||
makeDirectoryAsync: async (uri) => {
|
||||
addDirectory(directoryUris, uri)
|
||||
},
|
||||
readAsStringAsync: async (uri) => {
|
||||
const content = fileByUri.get(uri)
|
||||
if (content === undefined) {
|
||||
throw new Error(`Missing fake file: ${uri}`)
|
||||
}
|
||||
|
||||
return content
|
||||
},
|
||||
readDirectoryAsync: async (uri) => listDirectoryNames({ directoryUris, fileByUri, uri }),
|
||||
writeAsStringAsync: async (uri, content) => {
|
||||
addParentDirectories(directoryUris, uri)
|
||||
fileByUri.set(uri, content)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectoryNames(input: {
|
||||
directoryUris: Set<string>
|
||||
fileByUri: Map<string, string>
|
||||
uri: string
|
||||
}) {
|
||||
const names = new Set<string>()
|
||||
const prefix = `${input.uri.replace(/\/+$/, '')}/`
|
||||
for (const uri of [...input.directoryUris, ...input.fileByUri.keys()]) {
|
||||
const remaining = uri.startsWith(prefix) ? uri.slice(prefix.length) : ''
|
||||
const name = remaining.split('/')[0]
|
||||
if (name) {
|
||||
names.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
return [...names].sort()
|
||||
}
|
||||
|
||||
function addParentDirectories(directoryUris: Set<string>, fileUri: string) {
|
||||
addDirectory(directoryUris, fileUri.split('/').slice(0, -1).join('/'))
|
||||
}
|
||||
|
||||
function addDirectory(directoryUris: Set<string>, uri: string) {
|
||||
const segments = uri.split('/')
|
||||
for (const index of segments.keys()) {
|
||||
if (index > 1) {
|
||||
directoryUris.add(segments.slice(0, index + 1).join('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
208
apps/mobile/src/mobileExpoVaultStorage.ts
Normal file
208
apps/mobile/src/mobileExpoVaultStorage.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { MobileVaultConfig } from './mobileVaultConfig'
|
||||
import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultStorage'
|
||||
|
||||
export type ExpoMobileVaultFileInfo = {
|
||||
exists: boolean
|
||||
isDirectory?: boolean
|
||||
modificationTime?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type ExpoMobileVaultFileSystem = {
|
||||
deleteAsync: (uri: string, options?: { idempotent?: boolean }) => Promise<void>
|
||||
documentDirectory: string | null
|
||||
getInfoAsync: (uri: string) => Promise<ExpoMobileVaultFileInfo>
|
||||
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
|
||||
readAsStringAsync: (uri: string, options?: ExpoMobileVaultReadOptions) => Promise<string>
|
||||
readDirectoryAsync: (uri: string) => Promise<string[]>
|
||||
writeAsStringAsync: (uri: string, content: string, options?: ExpoMobileVaultWriteOptions) => Promise<void>
|
||||
}
|
||||
|
||||
type VaultPathInput = {
|
||||
path: string
|
||||
}
|
||||
|
||||
export type ExpoMobileVaultReadOptions = {
|
||||
encoding?: 'base64' | 'utf8'
|
||||
}
|
||||
|
||||
export type ExpoMobileVaultWriteOptions = ExpoMobileVaultReadOptions
|
||||
|
||||
type DirectoryListingInput = {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
rootUri: string
|
||||
directoryPath: string
|
||||
}
|
||||
|
||||
type DirectoryEntryInput = DirectoryListingInput & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type VaultFileInput = {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
rootUri: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type DirectoryInput = {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
uri: string
|
||||
}
|
||||
|
||||
export function createExpoMobileVaultStorage(
|
||||
fileSystem: ExpoMobileVaultFileSystem,
|
||||
): MobileVaultStorageDriver {
|
||||
return {
|
||||
deleteMarkdownFile: (vault, path) => deleteMarkdownFile(fileSystem, vault, path),
|
||||
listMarkdownFiles: (vault) => listMarkdownFiles(fileSystem, vault),
|
||||
readMarkdownFile: (vault, path) => readMarkdownFile(fileSystem, vault, path),
|
||||
writeMarkdownFile: (vault, path, content) => writeMarkdownFile(fileSystem, vault, path, content),
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMarkdownFile(
|
||||
fileSystem: ExpoMobileVaultFileSystem,
|
||||
vault: MobileVaultConfig,
|
||||
path: string,
|
||||
) {
|
||||
const rootUri = vaultRootUri(fileSystem, vault)
|
||||
const safePath = normalizeVaultPath({ path })
|
||||
await fileSystem.deleteAsync(appendUri({ root: rootUri, segments: [safePath] }), { idempotent: true })
|
||||
}
|
||||
|
||||
async function listMarkdownFiles(
|
||||
fileSystem: ExpoMobileVaultFileSystem,
|
||||
vault: MobileVaultConfig,
|
||||
): Promise<MobileVaultFile[]> {
|
||||
const rootUri = await ensureVaultRoot(fileSystem, vault)
|
||||
const paths = await listMarkdownPaths({ fileSystem, rootUri, directoryPath: '' })
|
||||
const files = await Promise.all(paths.map((path) => readVaultFile({ fileSystem, rootUri, path })))
|
||||
|
||||
return files.sort((left, right) => left.path.localeCompare(right.path))
|
||||
}
|
||||
|
||||
async function readMarkdownFile(
|
||||
fileSystem: ExpoMobileVaultFileSystem,
|
||||
vault: MobileVaultConfig,
|
||||
path: string,
|
||||
) {
|
||||
const rootUri = vaultRootUri(fileSystem, vault)
|
||||
const safePath = normalizeVaultPath({ path })
|
||||
const fileUri = appendUri({ root: rootUri, segments: [safePath] })
|
||||
const info = await fileSystem.getInfoAsync(fileUri)
|
||||
|
||||
return info.exists && !info.isDirectory ? fileSystem.readAsStringAsync(fileUri) : null
|
||||
}
|
||||
|
||||
async function writeMarkdownFile(
|
||||
fileSystem: ExpoMobileVaultFileSystem,
|
||||
vault: MobileVaultConfig,
|
||||
path: string,
|
||||
content: string,
|
||||
) {
|
||||
const rootUri = await ensureVaultRoot(fileSystem, vault)
|
||||
const safePath = normalizeVaultPath({ path })
|
||||
await ensureParentDirectory({ fileSystem, rootUri, path: safePath })
|
||||
await fileSystem.writeAsStringAsync(appendUri({ root: rootUri, segments: [safePath] }), content)
|
||||
}
|
||||
|
||||
async function listMarkdownPaths(input: DirectoryListingInput): Promise<string[]> {
|
||||
const directoryUri = appendUri({ root: input.rootUri, segments: [input.directoryPath] })
|
||||
const names = await input.fileSystem.readDirectoryAsync(directoryUri)
|
||||
const paths = await Promise.all(
|
||||
names.map((name) => listDirectoryEntry({ ...input, name })),
|
||||
)
|
||||
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
async function listDirectoryEntry(input: DirectoryEntryInput): Promise<string[]> {
|
||||
if (isUnsafeSegment(input.name)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const path = input.directoryPath ? `${input.directoryPath}/${input.name}` : input.name
|
||||
const info = await input.fileSystem.getInfoAsync(appendUri({ root: input.rootUri, segments: [path] }))
|
||||
if (!info.exists) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (info.isDirectory) {
|
||||
return listMarkdownPaths({ fileSystem: input.fileSystem, rootUri: input.rootUri, directoryPath: path })
|
||||
}
|
||||
|
||||
return path.endsWith('.md') ? [path] : []
|
||||
}
|
||||
|
||||
async function readVaultFile(input: VaultFileInput): Promise<MobileVaultFile> {
|
||||
return {
|
||||
path: input.path,
|
||||
content: await input.fileSystem.readAsStringAsync(
|
||||
appendUri({ root: input.rootUri, segments: [input.path] }),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureVaultRoot(fileSystem: ExpoMobileVaultFileSystem, vault: MobileVaultConfig) {
|
||||
const rootUri = vaultRootUri(fileSystem, vault)
|
||||
await ensureDirectory({ fileSystem, uri: rootUri })
|
||||
|
||||
return rootUri
|
||||
}
|
||||
|
||||
async function ensureParentDirectory(input: VaultFileInput) {
|
||||
const parentPath = input.path.split('/').slice(0, -1).join('/')
|
||||
if (parentPath) {
|
||||
await ensureDirectory({
|
||||
fileSystem: input.fileSystem,
|
||||
uri: appendUri({ root: input.rootUri, segments: [parentPath] }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDirectory(input: DirectoryInput) {
|
||||
const info = await input.fileSystem.getInfoAsync(input.uri)
|
||||
if (info.exists && !info.isDirectory) {
|
||||
throw new Error(`Mobile vault path is not a directory: ${input.uri}`)
|
||||
}
|
||||
|
||||
if (!info.exists) {
|
||||
await input.fileSystem.makeDirectoryAsync(input.uri, { intermediates: true })
|
||||
}
|
||||
}
|
||||
|
||||
function vaultRootUri(fileSystem: ExpoMobileVaultFileSystem, vault: MobileVaultConfig) {
|
||||
if (!fileSystem.documentDirectory) {
|
||||
throw new Error('Expo FileSystem documentDirectory is unavailable')
|
||||
}
|
||||
|
||||
return appendUri({
|
||||
root: fileSystem.documentDirectory,
|
||||
segments: ['vaults', vault.storage.directoryName || vault.id],
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeVaultPath(input: VaultPathInput) {
|
||||
const path = input.path
|
||||
const segments = path.replaceAll('\\', '/').split('/').filter(Boolean)
|
||||
if (isUnsafeVaultPath({ path, segments })) {
|
||||
throw new Error(`Unsafe mobile vault path: ${path}`)
|
||||
}
|
||||
|
||||
return segments.join('/')
|
||||
}
|
||||
|
||||
function isUnsafeVaultPath(input: VaultPathInput & { segments: string[] }) {
|
||||
return !input.path.endsWith('.md') || input.segments.length === 0 || input.segments.some(isUnsafeSegment)
|
||||
}
|
||||
|
||||
function isUnsafeSegment(segment: string) {
|
||||
return segment === '.' || segment === '..' || segment.includes('/')
|
||||
}
|
||||
|
||||
function appendUri(input: { root: string; segments: string[] }) {
|
||||
const base = input.root.replace(/\/+$/, '')
|
||||
const path = input.segments.filter(Boolean).join('/')
|
||||
|
||||
return path ? `${base}/${path}` : base
|
||||
}
|
||||
87
apps/mobile/src/mobileGitAuthentication.test.ts
Normal file
87
apps/mobile/src/mobileGitAuthentication.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
|
||||
import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication'
|
||||
|
||||
describe('authenticateMobileGitSyncPlan', () => {
|
||||
it('ignores plans that do not need authentication', async () => {
|
||||
await expect(authenticateMobileGitSyncPlan({
|
||||
credentialStorage: noopCredentialStorage(),
|
||||
createGitHubOAuthSession: failingSession,
|
||||
now: () => '2026-05-05T12:00:00.000Z',
|
||||
plan: { primaryAction: null, state: 'localOnly' },
|
||||
})).resolves.toEqual({ state: 'ignored' })
|
||||
})
|
||||
|
||||
it('connects GitHub auth-required plans through the OAuth session', async () => {
|
||||
const credentialStorage = memoryCredentialStorage()
|
||||
|
||||
await expect(authenticateMobileGitSyncPlan({
|
||||
credentialStorage,
|
||||
createGitHubOAuthSession: () => ({
|
||||
authorize: async () => ({
|
||||
state: 'succeeded',
|
||||
token: { accessToken: 'token', tokenType: 'bearer' },
|
||||
}),
|
||||
}),
|
||||
now: () => '2026-05-05T12:00:00.000Z',
|
||||
plan: {
|
||||
authStrategy: 'githubOAuth',
|
||||
host: 'github.com',
|
||||
primaryAction: 'authenticate',
|
||||
state: 'authRequired',
|
||||
},
|
||||
})).resolves.toEqual({ state: 'connected' })
|
||||
|
||||
await expect(credentialStorage.loadState({ host: 'github.com', strategy: 'githubOAuth' }))
|
||||
.resolves.toEqual({ state: 'available' })
|
||||
})
|
||||
|
||||
it('fails unsupported SSH authentication without starting OAuth', async () => {
|
||||
await expect(authenticateMobileGitSyncPlan({
|
||||
credentialStorage: noopCredentialStorage(),
|
||||
createGitHubOAuthSession: failingSession,
|
||||
now: () => '2026-05-05T12:00:00.000Z',
|
||||
plan: {
|
||||
authStrategy: 'sshKey',
|
||||
host: 'git.example.com',
|
||||
primaryAction: 'authenticate',
|
||||
state: 'authRequired',
|
||||
},
|
||||
})).resolves.toEqual({
|
||||
message: 'SSH credential setup is not available yet.',
|
||||
state: 'failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function memoryCredentialStorage(): MobileGitCredentialStorage {
|
||||
let isAvailable = false
|
||||
|
||||
return {
|
||||
loadRecord: async () => null,
|
||||
loadState: async () => isAvailable ? { state: 'available' } : { state: 'missing' },
|
||||
remove: async () => {
|
||||
isAvailable = false
|
||||
},
|
||||
saveRecord: async () => {
|
||||
isAvailable = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function noopCredentialStorage(): MobileGitCredentialStorage {
|
||||
return {
|
||||
loadRecord: async () => null,
|
||||
loadState: async () => ({ state: 'missing' }),
|
||||
remove: async () => {},
|
||||
saveRecord: async () => {},
|
||||
}
|
||||
}
|
||||
|
||||
function failingSession() {
|
||||
return {
|
||||
authorize: async () => {
|
||||
throw new Error('should not start OAuth')
|
||||
},
|
||||
}
|
||||
}
|
||||
55
apps/mobile/src/mobileGitAuthentication.ts
Normal file
55
apps/mobile/src/mobileGitAuthentication.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
|
||||
import type { MobileGitSyncPlan } from './mobileGitSyncPlan'
|
||||
import { connectMobileGitHubOAuth, type MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
|
||||
|
||||
export type MobileGitAuthenticationResult =
|
||||
| {
|
||||
state: 'connected'
|
||||
}
|
||||
| {
|
||||
state: 'cancelled'
|
||||
}
|
||||
| {
|
||||
message: string
|
||||
state: 'failed'
|
||||
}
|
||||
| {
|
||||
state: 'ignored'
|
||||
}
|
||||
|
||||
export async function authenticateMobileGitSyncPlan({
|
||||
credentialStorage,
|
||||
createGitHubOAuthSession,
|
||||
now,
|
||||
plan,
|
||||
}: {
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
createGitHubOAuthSession: () => MobileGitHubOAuthSession
|
||||
now: () => string
|
||||
plan: MobileGitSyncPlan
|
||||
}): Promise<MobileGitAuthenticationResult> {
|
||||
if (!canAuthenticate(plan)) {
|
||||
return { state: 'ignored' }
|
||||
}
|
||||
|
||||
if (authStrategy(plan) !== 'githubOAuth') {
|
||||
return {
|
||||
message: 'SSH credential setup is not available yet.',
|
||||
state: 'failed',
|
||||
}
|
||||
}
|
||||
|
||||
return connectMobileGitHubOAuth({
|
||||
credentialStorage,
|
||||
now,
|
||||
session: createGitHubOAuthSession(),
|
||||
})
|
||||
}
|
||||
|
||||
function canAuthenticate(plan: MobileGitSyncPlan) {
|
||||
return plan.state === 'authRequired' || plan.state === 'failed'
|
||||
}
|
||||
|
||||
function authStrategy(plan: Extract<MobileGitSyncPlan, { state: 'authRequired' | 'failed' }>) {
|
||||
return plan.state === 'authRequired' ? plan.authStrategy : plan.remote.authStrategy
|
||||
}
|
||||
62
apps/mobile/src/mobileGitCredentialStateForVault.test.ts
Normal file
62
apps/mobile/src/mobileGitCredentialStateForVault.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createMobileGitCredentialRecord,
|
||||
type MobileGitCredentialStorage,
|
||||
} from './mobileGitCredentialStorage'
|
||||
import { loadMobileGitCredentialStateForVault } from './mobileGitCredentialStateForVault'
|
||||
|
||||
describe('loadMobileGitCredentialStateForVault', () => {
|
||||
it('keeps local-only vaults credential-missing without hitting secure storage', async () => {
|
||||
await expect(loadMobileGitCredentialStateForVault({
|
||||
credentialStorage: failingCredentialStorage(),
|
||||
vault: { id: 'personal', name: 'Personal Journal' },
|
||||
})).resolves.toEqual({ state: 'missing' })
|
||||
})
|
||||
|
||||
it('loads credential state for a remote-backed vault auth requirement', async () => {
|
||||
const credentialStorage = memoryCredentialStorage()
|
||||
await credentialStorage.saveRecord(createMobileGitCredentialRecord({
|
||||
requirement: { host: 'github.com', strategy: 'githubOAuth' },
|
||||
storedAt: '2026-05-05T12:00:00.000Z',
|
||||
}))
|
||||
|
||||
await expect(loadMobileGitCredentialStateForVault({
|
||||
credentialStorage,
|
||||
vault: {
|
||||
id: 'tolaria',
|
||||
name: 'Tolaria',
|
||||
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
|
||||
},
|
||||
})).resolves.toEqual({ state: 'available' })
|
||||
})
|
||||
})
|
||||
|
||||
function memoryCredentialStorage(): MobileGitCredentialStorage {
|
||||
const records = new Map<string, ReturnType<typeof createMobileGitCredentialRecord>>()
|
||||
|
||||
return {
|
||||
loadRecord: async (requirement) => records.get(`${requirement.strategy}:${requirement.host}`) ?? null,
|
||||
loadState: async (requirement) => records.has(`${requirement.strategy}:${requirement.host}`)
|
||||
? { state: 'available' }
|
||||
: { state: 'missing' },
|
||||
remove: async (requirement) => {
|
||||
records.delete(`${requirement.strategy}:${requirement.host}`)
|
||||
},
|
||||
saveRecord: async (record) => {
|
||||
records.set(`${record.strategy}:${record.host}`, record)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function failingCredentialStorage(): MobileGitCredentialStorage {
|
||||
return {
|
||||
loadRecord: async () => {
|
||||
throw new Error('should not load credentials')
|
||||
},
|
||||
loadState: async () => {
|
||||
throw new Error('should not load credentials')
|
||||
},
|
||||
remove: async () => {},
|
||||
saveRecord: async () => {},
|
||||
}
|
||||
}
|
||||
19
apps/mobile/src/mobileGitCredentialStateForVault.ts
Normal file
19
apps/mobile/src/mobileGitCredentialStateForVault.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
|
||||
import type { MobileGitCredentialState } from './mobileGitSyncPlan'
|
||||
import { createMobileVaultConfig } from './mobileVaultConfig'
|
||||
import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
|
||||
export async function loadMobileGitCredentialStateForVault({
|
||||
credentialStorage,
|
||||
vault,
|
||||
}: {
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
vault: MobileVaultMetadata
|
||||
}): Promise<MobileGitCredentialState> {
|
||||
const result = createMobileVaultConfig(vault)
|
||||
if (!result.ok || result.config.sync.state === 'localOnly') {
|
||||
return { state: 'missing' }
|
||||
}
|
||||
|
||||
return credentialStorage.loadState(result.config.sync.authRequirement)
|
||||
}
|
||||
73
apps/mobile/src/mobileGitCredentialStorage.test.ts
Normal file
73
apps/mobile/src/mobileGitCredentialStorage.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createMobileGitCredentialKey,
|
||||
createMobileGitCredentialRecord,
|
||||
mobileGitCredentialState,
|
||||
parseMobileGitCredentialRecord,
|
||||
serializeMobileGitCredentialRecord,
|
||||
} from './mobileGitCredentialStorage'
|
||||
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
|
||||
|
||||
describe('mobile Git credential storage model', () => {
|
||||
it('creates stable host-normalized secure storage keys', () => {
|
||||
expect(createMobileGitCredentialKey(githubRequirement(' GitHub.COM '))).toBe(
|
||||
'tolaria:git-credential:githubOAuth:github.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a credential presence record for the required auth strategy', () => {
|
||||
expect(createMobileGitCredentialRecord({
|
||||
requirement: githubRequirement('github.com'),
|
||||
storedAt: '2026-05-05T11:00:00.000Z',
|
||||
})).toEqual({
|
||||
host: 'github.com',
|
||||
kind: 'githubOAuthToken',
|
||||
strategy: 'githubOAuth',
|
||||
storedAt: '2026-05-05T11:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(createMobileGitCredentialRecord({
|
||||
requirement: sshRequirement('git.example.com'),
|
||||
storedAt: '2026-05-05T11:00:00.000Z',
|
||||
})).toMatchObject({
|
||||
kind: 'sshKeyReference',
|
||||
strategy: 'sshKey',
|
||||
})
|
||||
})
|
||||
|
||||
it('loads available state only when the record matches the required host and strategy', () => {
|
||||
const record = createMobileGitCredentialRecord({
|
||||
requirement: githubRequirement('github.com'),
|
||||
storedAt: '2026-05-05T11:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(mobileGitCredentialState({
|
||||
record,
|
||||
requirement: githubRequirement('github.com'),
|
||||
})).toEqual({ state: 'available' })
|
||||
|
||||
expect(mobileGitCredentialState({
|
||||
record,
|
||||
requirement: sshRequirement('github.com'),
|
||||
})).toEqual({ state: 'missing' })
|
||||
})
|
||||
|
||||
it('parses stored records defensively', () => {
|
||||
const record = createMobileGitCredentialRecord({
|
||||
requirement: githubRequirement('github.com'),
|
||||
storedAt: '2026-05-05T11:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(parseMobileGitCredentialRecord(serializeMobileGitCredentialRecord(record))).toEqual(record)
|
||||
expect(parseMobileGitCredentialRecord('{')).toBeNull()
|
||||
expect(parseMobileGitCredentialRecord(JSON.stringify({ host: 'github.com' }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function githubRequirement(host: string): MobileVaultAuthRequirement {
|
||||
return { host, strategy: 'githubOAuth' }
|
||||
}
|
||||
|
||||
function sshRequirement(host: string): MobileVaultAuthRequirement {
|
||||
return { host, strategy: 'sshKey' }
|
||||
}
|
||||
148
apps/mobile/src/mobileGitCredentialStorage.ts
Normal file
148
apps/mobile/src/mobileGitCredentialStorage.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { MobileGitAuthStrategy } from './mobileGitRemote'
|
||||
import type { MobileGitCredentialState } from './mobileGitSyncPlan'
|
||||
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
|
||||
|
||||
export type MobileGitCredentialKind = 'githubOAuthToken' | 'sshKeyReference'
|
||||
|
||||
export type MobileGitCredentialSecret = {
|
||||
accessToken: string
|
||||
scope?: string
|
||||
tokenType: string
|
||||
}
|
||||
|
||||
export type MobileGitCredentialRecord = {
|
||||
host: string
|
||||
kind: MobileGitCredentialKind
|
||||
secret?: MobileGitCredentialSecret
|
||||
strategy: MobileGitAuthStrategy
|
||||
storedAt: string
|
||||
}
|
||||
|
||||
export type MobileGitCredentialStorage = {
|
||||
loadRecord: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialRecord | null>
|
||||
loadState: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialState>
|
||||
remove: (requirement: MobileVaultAuthRequirement) => Promise<void>
|
||||
saveRecord: (record: MobileGitCredentialRecord) => Promise<void>
|
||||
}
|
||||
|
||||
export function createMobileGitCredentialRecord({
|
||||
requirement,
|
||||
secret,
|
||||
storedAt,
|
||||
}: {
|
||||
requirement: MobileVaultAuthRequirement
|
||||
secret?: MobileGitCredentialSecret
|
||||
storedAt: string
|
||||
}): MobileGitCredentialRecord {
|
||||
return {
|
||||
host: normalizeCredentialHost(requirement.host),
|
||||
kind: credentialKind(requirement.strategy),
|
||||
...(secret ? { secret } : {}),
|
||||
strategy: requirement.strategy,
|
||||
storedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function createMobileGitCredentialKey(requirement: MobileVaultAuthRequirement) {
|
||||
return [
|
||||
'tolaria',
|
||||
'git-credential',
|
||||
requirement.strategy,
|
||||
normalizeCredentialHost(requirement.host),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
export function parseMobileGitCredentialRecord(content: string | null): MobileGitCredentialRecord | null {
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeMobileGitCredentialRecord(JSON.parse(content))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function mobileGitCredentialState({
|
||||
record,
|
||||
requirement,
|
||||
}: {
|
||||
record: MobileGitCredentialRecord | null
|
||||
requirement: MobileVaultAuthRequirement
|
||||
}): MobileGitCredentialState {
|
||||
return record && matchesRequirement({ record, requirement })
|
||||
? { state: 'available' }
|
||||
: { state: 'missing' }
|
||||
}
|
||||
|
||||
export function serializeMobileGitCredentialRecord(record: MobileGitCredentialRecord) {
|
||||
return JSON.stringify(record)
|
||||
}
|
||||
|
||||
function normalizeMobileGitCredentialRecord(value: unknown): MobileGitCredentialRecord | null {
|
||||
if (!isCredentialRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
host: normalizeCredentialHost(value.host),
|
||||
kind: value.kind,
|
||||
...(isCredentialSecret(value.secret) ? { secret: value.secret } : {}),
|
||||
strategy: value.strategy,
|
||||
storedAt: value.storedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function matchesRequirement({
|
||||
record,
|
||||
requirement,
|
||||
}: {
|
||||
record: MobileGitCredentialRecord
|
||||
requirement: MobileVaultAuthRequirement
|
||||
}) {
|
||||
return record.strategy === requirement.strategy
|
||||
&& record.host === normalizeCredentialHost(requirement.host)
|
||||
&& record.kind === credentialKind(requirement.strategy)
|
||||
}
|
||||
|
||||
function credentialKind(strategy: MobileGitAuthStrategy): MobileGitCredentialKind {
|
||||
return strategy === 'githubOAuth' ? 'githubOAuthToken' : 'sshKeyReference'
|
||||
}
|
||||
|
||||
function normalizeCredentialHost(host: string) {
|
||||
return host.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isCredentialRecord(value: unknown): value is MobileGitCredentialRecord {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& hasText((value as MobileGitCredentialRecord).host)
|
||||
&& isCredentialKind((value as MobileGitCredentialRecord).kind)
|
||||
&& isCredentialStrategy((value as MobileGitCredentialRecord).strategy)
|
||||
&& hasText((value as MobileGitCredentialRecord).storedAt)
|
||||
}
|
||||
|
||||
function hasText(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function isCredentialKind(value: unknown): value is MobileGitCredentialKind {
|
||||
return value === 'githubOAuthToken' || value === 'sshKeyReference'
|
||||
}
|
||||
|
||||
function isCredentialStrategy(value: unknown): value is MobileGitAuthStrategy {
|
||||
return value === 'githubOAuth' || value === 'sshKey'
|
||||
}
|
||||
|
||||
function isCredentialSecret(value: unknown): value is MobileGitCredentialSecret {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& hasText((value as MobileGitCredentialSecret).accessToken)
|
||||
&& hasText((value as MobileGitCredentialSecret).tokenType)
|
||||
&& isOptionalText((value as MobileGitCredentialSecret).scope)
|
||||
}
|
||||
|
||||
function isOptionalText(value: unknown): value is string | undefined {
|
||||
return value === undefined || hasText(value)
|
||||
}
|
||||
62
apps/mobile/src/mobileGitHubOAuth.test.ts
Normal file
62
apps/mobile/src/mobileGitHubOAuth.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createMobileGitHubOAuthRequest,
|
||||
normalizeMobileGitHubAuthorizationResult,
|
||||
} from './mobileGitHubOAuth'
|
||||
|
||||
describe('createMobileGitHubOAuthRequest', () => {
|
||||
it('builds a PKCE authorization-code request for GitHub OAuth', () => {
|
||||
expect(createMobileGitHubOAuthRequest({
|
||||
clientId: ' abc123 ',
|
||||
redirectUri: ' tolaria://oauth/github ',
|
||||
})).toEqual({
|
||||
ok: true,
|
||||
request: {
|
||||
clientId: 'abc123',
|
||||
extraParams: { allow_signup: 'true' },
|
||||
redirectUri: 'tolaria://oauth/github',
|
||||
responseType: 'code',
|
||||
scopes: ['repo'],
|
||||
usePKCE: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ clientId: '', error: 'missingClientId', redirectUri: 'tolaria://oauth/github' },
|
||||
{ clientId: 'abc123', error: 'missingRedirectUri', redirectUri: '' },
|
||||
] as const)('rejects $error', ({ clientId, error, redirectUri }) => {
|
||||
expect(createMobileGitHubOAuthRequest({ clientId, redirectUri })).toEqual({
|
||||
error,
|
||||
ok: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMobileGitHubAuthorizationResult', () => {
|
||||
it('extracts the temporary authorization code', () => {
|
||||
expect(normalizeMobileGitHubAuthorizationResult({
|
||||
params: { code: 'temporary-code' },
|
||||
type: 'success',
|
||||
})).toEqual({
|
||||
code: 'temporary-code',
|
||||
state: 'authorized',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps user cancellation separate from auth failure', () => {
|
||||
expect(normalizeMobileGitHubAuthorizationResult({ type: 'cancel' })).toEqual({
|
||||
state: 'cancelled',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes provider errors without exposing raw result objects', () => {
|
||||
expect(normalizeMobileGitHubAuthorizationResult({
|
||||
params: { error: 'access_denied' },
|
||||
type: 'error',
|
||||
})).toEqual({
|
||||
message: 'access_denied',
|
||||
state: 'failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
98
apps/mobile/src/mobileGitHubOAuth.ts
Normal file
98
apps/mobile/src/mobileGitHubOAuth.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export const mobileGitHubOAuthDiscovery = {
|
||||
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
|
||||
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
||||
}
|
||||
|
||||
export const defaultMobileGitHubOAuthScopes = ['repo']
|
||||
|
||||
export type MobileGitHubOAuthRequestConfig = {
|
||||
clientId: string
|
||||
extraParams: Record<string, string>
|
||||
redirectUri: string
|
||||
responseType: 'code'
|
||||
scopes: string[]
|
||||
usePKCE: true
|
||||
}
|
||||
|
||||
export type CreateMobileGitHubOAuthRequestResult =
|
||||
| {
|
||||
ok: true
|
||||
request: MobileGitHubOAuthRequestConfig
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
error: 'missingClientId' | 'missingRedirectUri'
|
||||
}
|
||||
|
||||
export type MobileGitHubAuthorizationResult =
|
||||
| {
|
||||
code: string
|
||||
state: 'authorized'
|
||||
}
|
||||
| {
|
||||
state: 'cancelled'
|
||||
}
|
||||
| {
|
||||
message: string
|
||||
state: 'failed'
|
||||
}
|
||||
|
||||
export function createMobileGitHubOAuthRequest({
|
||||
clientId,
|
||||
redirectUri,
|
||||
scopes = defaultMobileGitHubOAuthScopes,
|
||||
}: {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
scopes?: string[]
|
||||
}): CreateMobileGitHubOAuthRequestResult {
|
||||
if (!clientId.trim()) {
|
||||
return { error: 'missingClientId', ok: false }
|
||||
}
|
||||
|
||||
if (!redirectUri.trim()) {
|
||||
return { error: 'missingRedirectUri', ok: false }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
request: {
|
||||
clientId: clientId.trim(),
|
||||
extraParams: { allow_signup: 'true' },
|
||||
redirectUri: redirectUri.trim(),
|
||||
responseType: 'code',
|
||||
scopes,
|
||||
usePKCE: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMobileGitHubAuthorizationResult(result: {
|
||||
error?: { message?: string } | null
|
||||
params?: Record<string, string>
|
||||
type: string
|
||||
}): MobileGitHubAuthorizationResult {
|
||||
if (result.type === 'success' && result.params?.code) {
|
||||
return { code: result.params.code, state: 'authorized' }
|
||||
}
|
||||
|
||||
if (['cancel', 'dismiss'].includes(result.type)) {
|
||||
return { state: 'cancelled' }
|
||||
}
|
||||
|
||||
return {
|
||||
message: oauthFailureMessage(result),
|
||||
state: 'failed',
|
||||
}
|
||||
}
|
||||
|
||||
function oauthFailureMessage(result: {
|
||||
error?: { message?: string } | null
|
||||
params?: Record<string, string>
|
||||
type: string
|
||||
}) {
|
||||
return result.error?.message
|
||||
?? result.params?.error_description
|
||||
?? result.params?.error
|
||||
?? `GitHub OAuth ended with ${result.type}.`
|
||||
}
|
||||
15
apps/mobile/src/mobileGitHubOAuthClientId.ts
Normal file
15
apps/mobile/src/mobileGitHubOAuthClientId.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type MobileGitHubOAuthClientIdState =
|
||||
| {
|
||||
clientId: string
|
||||
state: 'configured'
|
||||
}
|
||||
| {
|
||||
state: 'missing'
|
||||
}
|
||||
|
||||
export function mobileGitHubOAuthClientIdState(
|
||||
env: Record<string, string | undefined> | undefined,
|
||||
): MobileGitHubOAuthClientIdState {
|
||||
const clientId = env?.EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID?.trim()
|
||||
return clientId ? { clientId, state: 'configured' } : { state: 'missing' }
|
||||
}
|
||||
20
apps/mobile/src/mobileGitHubOAuthEnvironment.test.ts
Normal file
20
apps/mobile/src/mobileGitHubOAuthEnvironment.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileGitHubOAuthClientIdState } from './mobileGitHubOAuthClientId'
|
||||
|
||||
describe('mobileGitHubOAuthClientIdState', () => {
|
||||
it('detects a configured Expo public GitHub OAuth client id', () => {
|
||||
expect(mobileGitHubOAuthClientIdState({
|
||||
EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID: ' abc123 ',
|
||||
})).toEqual({
|
||||
clientId: 'abc123',
|
||||
state: 'configured',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a missing client id when the env value is absent or blank', () => {
|
||||
expect(mobileGitHubOAuthClientIdState({})).toEqual({ state: 'missing' })
|
||||
expect(mobileGitHubOAuthClientIdState({
|
||||
EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID: ' ',
|
||||
})).toEqual({ state: 'missing' })
|
||||
})
|
||||
})
|
||||
26
apps/mobile/src/mobileGitHubOAuthEnvironment.ts
Normal file
26
apps/mobile/src/mobileGitHubOAuthEnvironment.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createNativeMobileGitHubOAuthSession } from './mobileNativeGitHubOAuthSession'
|
||||
import {
|
||||
mobileGitHubOAuthClientIdState,
|
||||
type MobileGitHubOAuthClientIdState,
|
||||
} from './mobileGitHubOAuthClientId'
|
||||
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
|
||||
|
||||
declare const process: { env?: Record<string, string | undefined> } | undefined
|
||||
|
||||
export function createNativeMobileGitHubOAuthSessionFromEnvironment(): MobileGitHubOAuthSession {
|
||||
const clientIdState = currentMobileGitHubOAuthClientIdState()
|
||||
if (clientIdState.state === 'missing') {
|
||||
return {
|
||||
authorize: async () => ({
|
||||
message: 'GitHub OAuth client ID is not configured.',
|
||||
state: 'failed',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return createNativeMobileGitHubOAuthSession({ clientId: clientIdState.clientId })
|
||||
}
|
||||
|
||||
export function currentMobileGitHubOAuthClientIdState(): MobileGitHubOAuthClientIdState {
|
||||
return mobileGitHubOAuthClientIdState(process?.env)
|
||||
}
|
||||
65
apps/mobile/src/mobileGitHubOAuthFlow.test.ts
Normal file
65
apps/mobile/src/mobileGitHubOAuthFlow.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
connectMobileGitHubOAuth,
|
||||
type MobileGitHubOAuthSession,
|
||||
} from './mobileGitHubOAuthFlow'
|
||||
import { createMobileSecureGitCredentialStorage } from './mobileSecureGitCredentialStorage'
|
||||
import type { MobileSecureStore } from './mobileSecureGitCredentialStorage'
|
||||
|
||||
describe('connectMobileGitHubOAuth', () => {
|
||||
it('stores a GitHub credential record after successful OAuth', async () => {
|
||||
const secureStore = createMemorySecureStore()
|
||||
const storage = createMobileSecureGitCredentialStorage(secureStore)
|
||||
|
||||
await expect(connectMobileGitHubOAuth({
|
||||
credentialStorage: storage,
|
||||
now: () => '2026-05-05T12:00:00.000Z',
|
||||
session: successfulSession(),
|
||||
})).resolves.toEqual({ state: 'connected' })
|
||||
|
||||
await expect(secureStore.getItemAsync('tolaria:git-credential:githubOAuth:github.com'))
|
||||
.resolves.toContain('"accessToken":"github-token"')
|
||||
await expect(storage.loadState({ host: 'github.com', strategy: 'githubOAuth' }))
|
||||
.resolves.toEqual({ state: 'available' })
|
||||
})
|
||||
|
||||
it('does not store credentials when the user cancels', async () => {
|
||||
const secureStore = createMemorySecureStore()
|
||||
|
||||
await expect(connectMobileGitHubOAuth({
|
||||
credentialStorage: createMobileSecureGitCredentialStorage(secureStore),
|
||||
now: () => '2026-05-05T12:00:00.000Z',
|
||||
session: { authorize: async () => ({ state: 'cancelled' }) },
|
||||
})).resolves.toEqual({ state: 'cancelled' })
|
||||
|
||||
await expect(secureStore.getItemAsync('tolaria:git-credential:githubOAuth:github.com'))
|
||||
.resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function successfulSession(): MobileGitHubOAuthSession {
|
||||
return {
|
||||
authorize: async () => ({
|
||||
state: 'succeeded',
|
||||
token: {
|
||||
accessToken: 'github-token',
|
||||
scope: 'repo',
|
||||
tokenType: 'bearer',
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function createMemorySecureStore(): MobileSecureStore {
|
||||
const values = new Map<string, string>()
|
||||
|
||||
return {
|
||||
deleteItemAsync: async (key) => {
|
||||
values.delete(key)
|
||||
},
|
||||
getItemAsync: async (key) => values.get(key) ?? null,
|
||||
setItemAsync: async (key, value) => {
|
||||
values.set(key, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
68
apps/mobile/src/mobileGitHubOAuthFlow.ts
Normal file
68
apps/mobile/src/mobileGitHubOAuthFlow.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
createMobileGitCredentialRecord,
|
||||
type MobileGitCredentialStorage,
|
||||
} from './mobileGitCredentialStorage'
|
||||
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
|
||||
|
||||
export type MobileGitHubOAuthToken = {
|
||||
accessToken: string
|
||||
scope?: string
|
||||
tokenType: string
|
||||
}
|
||||
|
||||
export type MobileGitHubOAuthSessionResult =
|
||||
| {
|
||||
state: 'succeeded'
|
||||
token: MobileGitHubOAuthToken
|
||||
}
|
||||
| {
|
||||
state: 'cancelled'
|
||||
}
|
||||
| {
|
||||
message: string
|
||||
state: 'failed'
|
||||
}
|
||||
|
||||
export type MobileGitHubOAuthSession = {
|
||||
authorize: () => Promise<MobileGitHubOAuthSessionResult>
|
||||
}
|
||||
|
||||
export type ConnectMobileGitHubOAuthResult =
|
||||
| {
|
||||
state: 'connected'
|
||||
}
|
||||
| {
|
||||
state: 'cancelled'
|
||||
}
|
||||
| {
|
||||
message: string
|
||||
state: 'failed'
|
||||
}
|
||||
|
||||
export async function connectMobileGitHubOAuth({
|
||||
credentialStorage,
|
||||
now,
|
||||
session,
|
||||
}: {
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
now: () => string
|
||||
session: MobileGitHubOAuthSession
|
||||
}): Promise<ConnectMobileGitHubOAuthResult> {
|
||||
const result = await session.authorize()
|
||||
|
||||
if (result.state !== 'succeeded') {
|
||||
return result
|
||||
}
|
||||
|
||||
await credentialStorage.saveRecord(createMobileGitCredentialRecord({
|
||||
requirement: githubRequirement(),
|
||||
secret: result.token,
|
||||
storedAt: now(),
|
||||
}))
|
||||
|
||||
return { state: 'connected' }
|
||||
}
|
||||
|
||||
export function githubRequirement(): MobileVaultAuthRequirement {
|
||||
return { host: 'github.com', strategy: 'githubOAuth' }
|
||||
}
|
||||
70
apps/mobile/src/mobileGitPrimaryAction.test.ts
Normal file
70
apps/mobile/src/mobileGitPrimaryAction.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileGitPrimaryActionForPlan } from './mobileGitPrimaryAction'
|
||||
import type { MobileGitRemote } from './mobileGitRemote'
|
||||
|
||||
describe('mobile git primary action', () => {
|
||||
it('routes missing credentials to authentication', () => {
|
||||
expect(mobileGitPrimaryActionForPlan({
|
||||
authStrategy: 'githubOAuth',
|
||||
host: 'github.com',
|
||||
primaryAction: 'authenticate',
|
||||
state: 'authRequired',
|
||||
})).toEqual({ state: 'authenticate' })
|
||||
})
|
||||
|
||||
it('routes ready pull and push states to transport execution', () => {
|
||||
expect(mobileGitPrimaryActionForPlan({
|
||||
canPull: true,
|
||||
canPush: false,
|
||||
primaryAction: 'pull',
|
||||
remote: remote(),
|
||||
state: 'ready',
|
||||
})).toMatchObject({ operation: 'pull', state: 'transport' })
|
||||
|
||||
expect(mobileGitPrimaryActionForPlan({
|
||||
canPull: true,
|
||||
canPush: true,
|
||||
primaryAction: 'push',
|
||||
remote: remote(),
|
||||
state: 'ready',
|
||||
})).toMatchObject({ operation: 'push', state: 'transport' })
|
||||
})
|
||||
|
||||
it('retries clone failures through authentication and sync failures through transport', () => {
|
||||
expect(mobileGitPrimaryActionForPlan({
|
||||
message: 'GitHub authentication failed.',
|
||||
operation: 'clone',
|
||||
primaryAction: 'retry',
|
||||
remote: remote(),
|
||||
state: 'failed',
|
||||
})).toEqual({ state: 'authenticate' })
|
||||
|
||||
expect(mobileGitPrimaryActionForPlan({
|
||||
message: 'Pull failed.',
|
||||
operation: 'pull',
|
||||
primaryAction: 'retry',
|
||||
remote: remote(),
|
||||
state: 'failed',
|
||||
})).toMatchObject({ operation: 'pull', state: 'transport' })
|
||||
})
|
||||
|
||||
it('ignores local-only and already syncing plans', () => {
|
||||
expect(mobileGitPrimaryActionForPlan({ primaryAction: null, state: 'localOnly' })).toEqual({ state: 'ignored' })
|
||||
expect(mobileGitPrimaryActionForPlan({
|
||||
operation: 'pull',
|
||||
primaryAction: null,
|
||||
remote: remote(),
|
||||
state: 'syncing',
|
||||
})).toEqual({ state: 'ignored' })
|
||||
})
|
||||
})
|
||||
|
||||
function remote(): MobileGitRemote {
|
||||
return {
|
||||
authStrategy: 'githubOAuth',
|
||||
host: 'github.com',
|
||||
owner: 'refactoringhq',
|
||||
repository: 'tolaria',
|
||||
url: 'https://github.com/refactoringhq/tolaria.git',
|
||||
}
|
||||
}
|
||||
52
apps/mobile/src/mobileGitPrimaryAction.ts
Normal file
52
apps/mobile/src/mobileGitPrimaryAction.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan'
|
||||
import type { MobileGitRemote } from './mobileGitRemote'
|
||||
|
||||
export type MobileGitTransportOperation = Exclude<MobileGitOperation, 'clone'>
|
||||
|
||||
export type MobileGitPrimaryAction =
|
||||
| {
|
||||
state: 'authenticate'
|
||||
}
|
||||
| {
|
||||
operation: MobileGitTransportOperation
|
||||
remote: MobileGitRemote
|
||||
state: 'transport'
|
||||
}
|
||||
| {
|
||||
state: 'ignored'
|
||||
}
|
||||
|
||||
export function mobileGitPrimaryActionForPlan(plan: MobileGitSyncPlan): MobileGitPrimaryAction {
|
||||
if (plan.state === 'authRequired') {
|
||||
return { state: 'authenticate' }
|
||||
}
|
||||
|
||||
if (plan.state === 'ready') {
|
||||
return transportAction({
|
||||
operation: plan.primaryAction,
|
||||
remote: plan.remote,
|
||||
})
|
||||
}
|
||||
|
||||
if (plan.state === 'failed') {
|
||||
return plan.operation === 'clone'
|
||||
? { state: 'authenticate' }
|
||||
: transportAction({ operation: plan.operation, remote: plan.remote })
|
||||
}
|
||||
|
||||
return { state: 'ignored' }
|
||||
}
|
||||
|
||||
function transportAction({
|
||||
operation,
|
||||
remote,
|
||||
}: {
|
||||
operation: MobileGitTransportOperation
|
||||
remote: MobileGitRemote
|
||||
}): MobileGitPrimaryAction {
|
||||
return {
|
||||
operation,
|
||||
remote,
|
||||
state: 'transport',
|
||||
}
|
||||
}
|
||||
46
apps/mobile/src/mobileGitRemote.test.ts
Normal file
46
apps/mobile/src/mobileGitRemote.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseMobileGitRemote } from './mobileGitRemote'
|
||||
|
||||
describe('mobile git remote parsing', () => {
|
||||
it('uses GitHub OAuth for GitHub HTTPS remotes', () => {
|
||||
expect(parseMobileGitRemote('https://github.com/refactoringhq/tolaria.git')).toEqual({
|
||||
url: 'https://github.com/refactoringhq/tolaria.git',
|
||||
host: 'github.com',
|
||||
owner: 'refactoringhq',
|
||||
repository: 'tolaria',
|
||||
authStrategy: 'githubOAuth',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses GitHub OAuth for GitHub SSH shorthand remotes', () => {
|
||||
expect(parseMobileGitRemote('git@github.com:refactoringhq/tolaria.git')).toMatchObject({
|
||||
host: 'github.com',
|
||||
owner: 'refactoringhq',
|
||||
repository: 'tolaria',
|
||||
authStrategy: 'githubOAuth',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses SSH keys for non-GitHub SSH remotes', () => {
|
||||
expect(parseMobileGitRemote('ssh://git@git.example.com/acme/notes.git')).toMatchObject({
|
||||
host: 'git.example.com',
|
||||
owner: 'acme',
|
||||
repository: 'notes',
|
||||
authStrategy: 'sshKey',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses SSH keys for non-GitHub HTTPS remotes', () => {
|
||||
expect(parseMobileGitRemote('https://gitlab.com/acme/notes.git')).toMatchObject({
|
||||
host: 'gitlab.com',
|
||||
owner: 'acme',
|
||||
repository: 'notes',
|
||||
authStrategy: 'sshKey',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-remote input', () => {
|
||||
expect(parseMobileGitRemote('/Users/luca/Laputa')).toBeNull()
|
||||
expect(parseMobileGitRemote('')).toBeNull()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user