Task — engineering-spec@1

"Tauri 2.x desktop app — macOS + Windows + Linux signed/notarised + auto-update + tray + quick capture + Full Disk Access"

doneTASK-MEMORY-104
module memory · class product · priority p1 · created 2026-05-15 · shipped 2026-05-23
depends on TASK-MEMORY-103 · blocks TASK-MEMORY-105

§1 — Description (BCP-14 normative)

A Tauri 2.x desktop app MUST bundle the memory sync daemon + a minimal UI for inspecting + searching local memory. Each component:

  1. MUST ship for macOS (universal Apple Silicon + Intel; signed + notarised), Windows (x64; EV-cert signed), Linux (.deb + AppImage). Each platform's release verified by OS-native code-signing checks.
  2. MUST auto-update via Tauri's built-in updater. Update manifests signed with a release-signing Ed25519 key; client verifies signature before applying. Signature failure → rollback + sev-2 alert; user notified via in-app banner.
  3. MUST run memory-sync (TASK-MEMORY-103) as an internal Tauri-managed process via tauri::async_runtime::spawn. The supervisor monitors health; auto-restart on panic with exponential backoff.
  4. MUST expose a system tray icon with quick actions:
  1. MUST show on the main dashboard:
  1. MUST support local search via TASK-MEMORY-108 API. Search box on dashboard; results pane with memory previews; click → open memory file.
  2. MUST request macOS Full Disk Access at first run (one-time; persisted by macOS). Without FDA, the app cannot read ~/.cyberos/memory/store/ (System Integrity Protection blocks). The prompt directs user to System Settings > Privacy & Security > Full Disk Access > [Memory.app toggle].
  3. MUST sandbox per OS:
  1. MUST support quick-capture: tray-accessible textbox; user types → app writes a quick_note memory row with auto-tag (date, source: tray, originator_device).
  2. MUST sign update manifests with the release-signing Ed25519 key (separate from memory signing key). Public key embedded in app binary at compile time; rotation requires app rebuild + re-release.
  3. MUST persist user settings (Cloud memory URL, sync interval, opt-in to crash reporting) in OS-standard config dir (~/Library/Application Support/cyberos/memory/ macOS; %APPDATA%/cyberos/memory/ Windows; ~/.config/cyberos/memory/ Linux).
  4. MUST support headless mode (Linux daemon only; no UI) via --headless flag for server installs.
  5. MUST target bundle size ≤ 30MB on macOS, ≤ 25MB on Windows. Tauri produces small binaries by design; size budget catches accidental dependency bloat.
  6. MUST report crashes via opt-in sentry-rust integration. Default opt-OUT (privacy-respecting); user can enable in Settings.
  7. SHOULD support multi-window (search in separate window; main dashboard always primary).
  8. SHOULD support localisation infrastructure for slice-3+ Vietnamese UI (i18n keys + translation files).

§2 — Why this design (rationale for humans)

Why Tauri 2.x over Electron (DEC-190)? Tauri produces 5-10× smaller binaries (Electron ~150MB; Tauri ~25MB). Native performance (Rust shell + native WebView vs Chromium). Rust-native means embedding memory daemon directly without IPC overhead. Trade-off: WebView2/WKWebView is platform-specific (vs Chromium's uniformity); mitigated by Svelte's compatibility.

Why signed + notarised on macOS (§1 #1)? macOS Gatekeeper blocks unsigned apps with scary warnings. Notarisation registers the app with Apple's malware-scan service. Without these, users can't install easily; with them, install is one-click.

Why EV cert on Windows (§1 #1)? Standard code-signing certs trigger SmartScreen warnings until reputation builds (months of installs). EV certs skip SmartScreen entirely. Cost is higher ($300-500/year vs $50) but worth it for first-impression.

Why auto-update via Tauri (DEC-193)? Manual updates miss security patches. Tauri's updater + signed manifests = secure, automatic. Rollback on signature failure prevents the "compromised update server pushes malicious binary" attack.

Why Full Disk Access prompt (DEC-192)? macOS SIP blocks app access to ~/.cyberos/memory/store/ by default. FDA is one-time per user; persists. The prompt is annoying once, then invisible. Without FDA, the app can't read its own data — broken UX.

Why hardened sandbox per OS (§1 #8)? Compromised app shouldn't be able to read other apps' data, install kernel extensions, etc. Hardened runtime + entitlements (macOS) + AppContainer (Windows) limits blast radius. Trade-off: more development friction; worth it for security posture.

Why quick-capture in tray (§1 #9)? Friction kills ad-hoc note-taking. Tray-accessible textbox = instant capture (Cmd+Shift+Space global hotkey, opens textbox). User types, presses Enter, row written. The auto-tag (date + source: tray) saves the manual taxonomy.

Why bundle size budget ≤ 30MB (§1 #13)? Download UX matters. 100MB+ binaries take minutes to download on slow connections; users abandon install. 30MB downloads in <30s on most connections.

Why opt-in crash reporting (§1 #14)? Default-on crash reporting violates privacy expectations (memory is the user's personal memory app). Opt-in respects user agency; UX still benefits from crashes when users opt in.

Why headless mode (§1 #12)? Linux server installs (e.g., always-on home server) don't need UI. The --headless flag runs sync daemon only; no tray; no main window. Useful for ops infrastructure.

Why update-manifest signing (§1 #10)? Update server compromise (Cloudflare R2 breach) could push malicious binary. Signature on manifest + verification before applying = the user's app refuses tampered updates. Public key embedded in app binary means even a compromised update server can't trick the client.

Why config in OS-standard dirs (§1 #11)? Each OS has conventions; respecting them means standard backup tools work, OS migration tools handle config, IT admins know where to look.


§3 — API contract

// services/memory/desktop/src-tauri/src/commands.rs
use tauri::State;

#[tauri::command]
async fn search_memory(query: String, state: State<'_, AppState>) -> Result<Vec<SearchResult>, String> {
    state.memory_search.search(&query).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn write_quick_note(text: String, state: State<'_, AppState>) -> Result<String, String> {
    let row = QuickNoteRow {
        text, ts_ns: chrono::Utc::now().timestamp_nanos(),
        source: "tray".into(), device_id: state.device_id,
        auto_tags: vec!["quick_note".into(), today_str()],
    };
    state.local_memory.write(row).await.map_err(|e| e.to_string())?;
    Ok("written".into())
}

#[tauri::command]
async fn get_sync_state(state: State<'_, AppState>) -> Result<SyncState, String> {
    Ok(SyncState {
        chain_head: state.local_memory.chain_head().await,
        last_sync_at: state.sync_supervisor.last_sync_at().await,
        last_sync_duration_ms: state.sync_supervisor.last_sync_duration_ms().await,
        disputed_pair_count: state.local_memory.disputed_pair_count().await,
        sync_class_breakdown: state.local_memory.sync_class_counts().await,
        cloud_state: state.sync_supervisor.cloud_state().await,
    })
}

#[derive(Serialize)]
pub struct SyncState {
    pub chain_head: String,
    pub last_sync_at: Option<DateTime<Utc>>,
    pub last_sync_duration_ms: u32,
    pub disputed_pair_count: u32,
    pub sync_class_breakdown: HashMap<String, u32>,
    pub cloud_state: String,    // "online" | "offline" | "syncing"
}
// services/memory/desktop/src-tauri/src/sync_supervisor.rs
pub struct SyncSupervisor {
    handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
    last_sync_at: Arc<RwLock<Option<DateTime<Utc>>>>,
    cloud_state: Arc<RwLock<CloudState>>,
}

impl SyncSupervisor {
    pub async fn start(&self, local: Arc<LocalMemory>, cloud: Arc<CloudMemoryClient>) {
        let handle = tokio::spawn(async move {
            let mut backoff = Duration::from_secs(1);
            loop {
                match memory_sync::sync_loop(&local, &cloud).await {
                    Ok(()) => break,
                    Err(e) => {
                        tracing::warn!(error = %e, "sync_loop crashed; restarting after {backoff:?}");
                        tokio::time::sleep(backoff).await;
                        backoff = (backoff * 2).min(Duration::from_secs(300));
                    }
                }
            }
        });
        *self.handle.lock().await = Some(handle);
    }
}
// services/memory/desktop/src-tauri/src/main.rs
#[derive(clap::Parser)]
struct Cli {
    #[arg(long)] headless: bool,
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();

    let local_memory = Arc::new(LocalMemory::open(memory_dir()).await.unwrap());
    let cloud = Arc::new(CloudMemoryClient::connect_from_config().await.unwrap());
    let supervisor = SyncSupervisor::new();
    supervisor.start(local_memory.clone(), cloud).await;

    if cli.headless {
        // Linux server mode: no UI; just sync.
        loop { tokio::time::sleep(Duration::from_secs(60)).await; }
    }

    tauri::Builder::default()
        .plugin(tauri_plugin_updater::Builder::new().build())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_notification::init())
        .setup(|app| {
            permissions::request_full_disk_access(app)?;
            tray::install(app)?;
            Ok(())
        })
        .manage(AppState {
            local_memory, sync_supervisor: supervisor,
            memory_search: Arc::new(MemorySearch::new()),
            device_id: load_device_id(),
        })
        .invoke_handler(tauri::generate_handler![search_memory, write_quick_note, get_sync_state])
        .run(tauri::generate_context!())
        .expect("error running memory app");
}
// services/memory/desktop/src-tauri/tauri.conf.json (excerpt)
{
  "tauri": {
    "bundle": {
      "identifier": "world.cyberos.memory",
      "category": "Productivity",
      "macOS": {
        "frameworks": [],
        "providerShortName": "CyberSkill",
        "signingIdentity": "Developer ID Application: CyberSkill (TEAMID)",
        "entitlements": "entitlements.plist",
        "minimumSystemVersion": "12.0"
      },
      "windows": {
        "certificateThumbprint": "<EV cert thumbprint>",
        "digestAlgorithm": "sha256",
        "timestampUrl": "http://timestamp.digicert.com",
        "wix": { "language": "en-US" }
      }
    },
    "updater": {
      "active": true,
      "endpoints": ["https://releases.cyberos.world/memory/{{target}}/{{current_version}}"],
      "dialog": false,
      "pubkey": "ed25519-public-key-base64..."
    }
  }
}
# services/memory/desktop/.github/workflows/release.yml
name: Release Memory App
on:
  push: { tags: ['memory-v*'] }

jobs:
  build:
    strategy:
      matrix:
        platform: [macos-latest, windows-latest, ubuntu-latest]
    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions-rust-lang/setup-rust-toolchain@v1
      - uses: pnpm/action-setup@v3
      - run: pnpm install
      - name: Build
        working-directory: apps/memory
        run: cargo tauri build
      - name: Sign macOS
        if: matrix.platform == 'macos-latest'
        run: ./scripts/sign-and-notarize-macos.sh
        env:
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_PASSWORD: ${{ secrets.APPLE_NOTARIZE_PASSWORD }}
      - name: Sign Windows
        if: matrix.platform == 'windows-latest'
        run: ./scripts/sign-windows.sh
      - name: Upload to R2
        run: aws s3 cp ./target/release/bundle/ s3://releases-cyberos/memory/ --recursive
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_KEY }}
          AWS_ENDPOINT_URL: https://<account>.r2.cloudflarestorage.com
      - name: Generate signed update manifest
        run: |
          ./scripts/generate-update-manifest.sh \
              --version $GITHUB_REF_NAME \
              --signing-key /tmp/release-signing-key.ed25519 \
              --output update-manifest.json
          aws s3 cp update-manifest.json s3://releases-cyberos/memory/latest.json

§4 — Acceptance criteria

  1. App installs on macOS (drag-to-Applications) without Gatekeeper warning.
  2. App installs on Windows (.msi double-click) without SmartScreen warning.
  3. App installs on Linux (.deb + AppImage both work).
  4. Updater pulls new release within 24h of publish (synthetic test publishes v1.1; v1.0 client picks up).
  5. Update signature failure → rollback + sev-2 alert + in-app banner.
  6. Sync daemon visible in tray; toggle works (pause/resume).
  7. Local search returns memories ≤ 250ms p95.
  8. Disputed-pair count badge updates in real-time as new conflicts arise.
  9. Quick-capture textbox writes a quick_note memory row with auto-tags.
  10. Signed release verifies on every OS — codesign --verify Memory.app passes; Windows EV cert valid; Linux .deb dpkg-sig valid.
  11. macOS Full Disk Access prompt appears on first run; persists after grant.
  12. Sync supervisor restarts daemon on crash with exponential backoff.
  13. Bundle size ≤ 30MB macOS / ≤ 25MB Windows.
  14. --headless flag runs sync daemon only; no UI.
  15. Crash reporting opt-in default off; can be toggled in Settings.
  16. Multi-window: search in separate window works.
  17. Tray quick action "Recent memories" shows last 10 rows from local Layer 1.

§5 — Verification

# Manual installation tests (D = demonstration)
# macOS
cd apps/memory && cargo tauri build --target universal-apple-darwin
codesign --verify --deep --strict ./src-tauri/target/release/bundle/macos/Memory.app
spctl --assess --type exec ./src-tauri/target/release/bundle/macos/Memory.app

# Windows
cd apps/memory && cargo tauri build
signtool verify /pa ./src-tauri/target/release/bundle/msi/Memory.msi

# Linux
cd apps/memory && cargo tauri build
dpkg-deb --info ./src-tauri/target/release/bundle/deb/memory_*.deb
// services/memory/desktop/src-tauri/tests/sync_supervisor_test.rs
#[tokio::test]
async fn supervisor_restarts_daemon_on_crash() {
    let supervisor = SyncSupervisor::new();
    let local = Arc::new(test_local_memory());
    let cloud = Arc::new(MockCloud::start());

    test_helper::inject_sync_panic_after(Duration::from_secs(1));
    supervisor.start(local, cloud).await;
    tokio::time::sleep(Duration::from_secs(5)).await;
    assert!(supervisor.is_running().await);   // restarted after panic
}
// services/memory/desktop/src/lib/__tests__/quick_capture.test.ts
test('quick_capture writes a row via Tauri command', async () => {
  const { invoke } = await import('@tauri-apps/api/core');
  await invoke('write_quick_note', { text: 'test note' });
  const state = await invoke('get_sync_state');
  expect(state.sync_class_breakdown.private).toBeGreaterThan(0);
});
# Auto-update test (D)
# Publish v1.0; install on test machine
# Publish v1.1
# Wait ≤ 24h
# Verify client auto-updates to v1.1
# Headless mode test
memory --headless &
sleep 5
ps aux | grep memory   # should be running
ls ~/.cyberos/memory/store/audit/  # should have latest binlog
# Bundle size test
cd apps/memory && cargo tauri build
ls -lh ./src-tauri/target/release/bundle/macos/Memory.app
# Assert: <= 30MB
ls -lh ./src-tauri/target/release/bundle/msi/Memory.msi
# Assert: <= 25MB

§6 — Implementation skeleton

See §3.

// services/memory/desktop/src-tauri/src/tray.rs
pub fn install(app: &mut tauri::App) -> tauri::Result<()> {
    let tray_menu = Menu::new()
        .add_item(MenuItem::with_id("open", "Open memory"))
        .add_item(MenuItem::with_id("quick_capture", "Quick Capture..."))
        .add_separator()
        .add_item(MenuItem::with_id("force_sync", "Force Sync Now"))
        .add_item(MenuItem::with_id("toggle_sync", "Pause Sync"))
        .add_separator()
        .add_item(MenuItem::with_id("recent", "Recent Memories"))
        .add_separator()
        .add_item(MenuItem::with_id("quit", "Quit"));
    let _tray = TrayIconBuilder::with_id("main")
        .menu(&tray_menu)
        .icon(app.default_window_icon().unwrap().clone())
        .on_menu_event(handle_tray_event)
        .build(app)?;
    Ok(())
}

§7 — Dependencies


§8 — Example payloads

Quick-capture row

{
  "kind": "quick_note",
  "ts_ns": 1747526400000000000,
  "body": "Remember to follow up with Stephen about the Bedrock cost spike.",
  "meta": {
    "sync_class": "private",
    "auto_tags": ["quick_note", "2026-05-15"]
  },
  "extra": {
    "source": "tray",
    "originator_device_id": "device-mbp"
  }
}

Sync state response

{
  "chain_head": "a3f9c8d7e6b5a4f3...",
  "last_sync_at": "2026-05-15T14:00:30.123Z",
  "last_sync_duration_ms": 2150,
  "disputed_pair_count": 2,
  "sync_class_breakdown": { "shareable": 1247, "private": 384 },
  "cloud_state": "online"
}

Update manifest (from R2)

{
  "version": "1.1.0",
  "notes": "Bug fixes + perf",
  "pub_date": "2026-05-15T00:00:00Z",
  "platforms": {
    "darwin-x86_64":   { "url": "https://releases.cyberos.world/memory/1.1.0/Memory-darwin-x86_64.app.tar.gz", "signature": "..." },
    "darwin-aarch64":  { "url": "https://releases.cyberos.world/memory/1.1.0/Memory-darwin-aarch64.app.tar.gz", "signature": "..." },
    "windows-x86_64":  { "url": "https://releases.cyberos.world/memory/1.1.0/Memory-windows-x86_64.msi", "signature": "..." }
  }
}

Update signature failure log

ERROR update_signature_invalid version=1.1.0
      expected_pubkey=ed25519-... actual=...
      Rollback applied; user notified via in-app banner
sev-2 memory_app_update_signature_failures_total incremented

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Full Disk Access not grantedMemory can't read ~/.cyberos/memory/store/App shows banner asking user to grantUser clicks System Settings
Updater failsnetwork errorSev-2 metric; manual update via "Check for Updates"Operator action
Update signature invalided25519 verify failsRollback applied; sev-2 alarmOperator investigates manifest source
WebView2 missing (Windows)Tauri prompts user to installOne-time installUser action
WKWebView crash (macOS)Tauri watchdogRestart WebViewSelf-heals
Sync daemon crashtokio task panicSupervisor restarts with exponential backoffSelf-heals
macOS Gatekeeper blocksNotarisation expired or invalidUser can't open appRe-notarise + push update
Windows SmartScreen warningEV cert reputation"More info" workaround for usersBuild cert reputation OR re-issue
Linux .deb missing dependencyapt install errorUser installs missingOne-time
Bundle size > budgetCI check at buildFailEngineer optimises deps
Crash report sent without opt-inprivacy violationSev-1 incidentInvestigate; force opt-out by default
Tray icon disappearsOS settings issueUser restarts appOS-specific
Multi-window state inconsistentknown limitationRefresh buttonSlice 3+ fix
Headless mode + UI flag passedclap errorExit 1User fixes args
Auto-update during memory writeraceUpdate applied at next launchBy design
Cloud memory config wrongsync daemon errorsSettings UI shows errorUser fixes config
Device ID collision (extremely unlikely)UUID randomnessN/AN/A
Tauri 2.x API breaking changecompile errorPR blockedEngineer pins version
Sentry-rust panic on disabledcatchNo-opBy design
Localisation file missingfallback to EnglishSev-3 alarmTranslation engineer

§11 — Notes


End of TASK-MEMORY-104. Status: done (implemented 2026-05-23).

As built (2026-07-02)

The desktop app lives at services/memory/desktop (Svelte + Tauri 2) exactly as the paths above say; noted here because a 2026-07-02 sweep initially looked for apps/tauri.