# PlanXO Desktop Sync – System Documentation & Issue Report > Last reviewed: March 2026 > Target platform: macOS Desktop (Flutter); Windows Desktop planned --- ## 1. System Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ PlanXO Desktop App (Flutter/Dart) │ │ main.dart │ └───────────────────────────┬─────────────────────────────────────┘ WebSocket (wss) │ HTTP (https / dio) ┌──────────────────┘──────────────────────┐ ▼ ▼ ┌─────────────────────┐ ┌──────────────────────────┐ │ WebSocket Bridge │ axios (HTTP)│ Asset Manager (Python) │ │ server.js (Node) │◄──────────────┤ asset_manager_app.py │ │ Port: WS_PORT | │ │ Port: 5002 │ │ Port: HTTP_PORT │ │ S3 + MongoDB │ └─────────┬───────────┘ └──────────────────────────┘ │ HTTP (axios) ▼ ┌─────────────────────┐ │ CMS Backend │ │ app.py (Flask) │ │ + MongoDB + JWT │ └─────────────────────┘ ``` ### Component Roles | Component | Language | Responsibility | |---|---|---| | `main.dart` | Flutter/Dart | Desktop UI, file watch, upload/download sync | | `server.js` | Node.js (Express + WS) | WebSocket relay, auth key registration/approval, message routing | | `asset_manager_app.py` | Python (Flask) | S3 operations: upload presign, download folder as ZIP, asset CRUD | | `app.py` | Python (Flask) | CMS logic: users, projects, auth key CRUD, JWT, role enforcement | ### Frontend Admin Settings | File | Purpose | |---|---| | `DesktopSyncSettings.js` | Sets global sync interval (stored in CMS backend via `/settings/desktop-sync`) | | `FolderSyncSettings.js` | Sets admin folder age filter (how many days back to sync; via `/settings/folder-sync`) | --- ## 2. Authentication Flow ``` Desktop App WebSocket Server CMS Backend Browser │ │ │ │ │─── WSS connect (apiKey, unique_id) ───────────────────► │ │ │ │ │ │ │─── register_auth_key ─────────► │ │ │ │─── POST /planxo/app/auth/register-key ──────────► │ │ │◄── {auth_key, browser_url} ───────────────────── │ │◄── auth_key_registered ───────│ │ │ │ │ │ │ │─── open browser with /planxo-auth/{auth_key} ─────────────────────────────────► │ │ │ │ │ │ (polling validate_auth every 2s) ──────────────────────► │ │ │ │ │ │ │ │ Admin approves in browser ◄────────────────│ │ │◄── POST /api/planxo/app/auth/{key}/approve ────── │ │◄── auth_approved (user, sync_interval) ────────────────── │ │ │ │ │ │ │ store auth key, start sync │ │ ``` --- ## 3. Sync Logic (Download Path) 1. After successful auth, `fetchFileList()` is called. 2. App fetches filtered folders list: `GET /planxo/folders/filtered` (HTTP, with `X-Auth-Key` header). 3. For each folder in the list, `_downloadAndExtractFolder(folder)` is called: - `GET /folders/download?folder=` → returns a ZIP stream. - ZIP is decoded with `archive` package. - For each file in ZIP: if local file does NOT exist OR remote mtime is newer → write to disk. - After extraction, `_primeKnownKeys(prefix)` fetches the S3 asset list into `_knownS3Keys`. - A `DirectoryWatcher` is started on the local folder. 4. Periodic sync (per-folder timer or global timer) calls `_downloadAndExtractFolder` again to check for remote changes. ## 4. Sync Logic (Upload Path) 1. `DirectoryWatcher` fires `ADD` or `MODIFY` events. 2. `_onLocalFileChanged(path, folderPrefix)` is called: - Debounces: skip if same file was processed < 1 second ago. - Computes SHA-256 hash. Compares to `_syncMeta[path]['hash']`. Skips if unchanged. - If file key is in `_knownS3Keys`: calls `_replaceExisting()` (presign-replace → PUT to S3 → complete-replace). - Else: calls `_uploadNew()` (presign-upload → PUT to S3 → register-upload). - Updates `_syncMeta` and persists to `~/.planxo_sync_meta.json`. 3. Periodic `_syncLocalChanges()` also scans all files and uploads modified ones (same logic). ## 5. Persistence Files (macOS `$HOME`) | File | Content | |---|---| | `~/.planxo_client` | Last used client name (subdomain) | | `~/.planxo_auth_key` | Saved auth key (plaintext) | | `~/.planxo_storage` | Storage base path | | `~/.planxo_sync_settings.json` | Per-folder sync enabled/interval | | `~/.planxo_sync_meta.json` | Per-file `{lastModified, hash}` for change detection | | `~/Library/.../sync_data.db` | SQLite event log via DatabaseHelper | ## 6. Role-Based Sync Intent (Design Goal) | Role | Intended Behaviour | |---|---| | **Normal user** | Sync only folders/projects assigned to them | | **Admin** | Sync all projects created within `folder_age_days` (configured in Frontend `FolderSyncSettings.js`) | The filtering is supposed to happen at `GET /planxo/folders/filtered` on the CMS backend. The desktop app currently passes `X-Auth-Key` so the backend knows who the user is. --- ## 7. Issues Found ### 🔴 Critical Bugs ### 🟠 Logic Bugs #### 7.16 `_buildLoginScreen` Shows WS Status But Never Initiates Connection The login screen shows a red/green dot for WS status, but `_connectWs()` is never called on app launch. The dot will always be red until the user clicks "Login". See also issue 7.14 above. --- #### 7.17 `MyApp` (Root Widget) Holds All State The entire app state including network connections, timers, and file watchers lives in `_MyAppState` which wraps `MaterialApp`. This is an anti-pattern in Flutter. A rebuild of `MyApp` disposes and re-creates all state including open WebSockets. **Recommendation:** Break out a `HomePage` stateful widget and keep `MyApp` as a thin shell containing only `MaterialApp` configuration. --- #### 7.18 Auth Key Stored as Plaintext File `~/.planxo_auth_key` is a plaintext file readable by any process running as the same user. On a shared machine this is a security risk. **Recommendation (macOS):** Use the macOS Keychain via the `flutter_secure_storage` package. --- #### 7.19 `_storageController` and `_clientController` Are Leaked These `TextEditingController` instances are initialized in `initState` but the `_storageController.text` is only set later in async callbacks (`_loadStoragePath`, `_loadClientName`). If the widget is disposed before these complete, there could be a "setState called after dispose" error. The async loaders call `setState()` without a `mounted` guard. **Fix:** Wrap all `setState()` calls inside async methods with `if (!mounted) return;`. --- #### 7.20 `_syncInterval` from Backend Not Passed to Per-Folder Timers `_syncInterval` (received from `auth_approved` or `auth_success`) is used only for `_periodicSyncTimer`. The per-folder timers (`_syncTimers`) use a locally configured interval. There is no reconciliation between the admin-set global interval from the frontend (`DesktopSyncSettings.js`) and the per-folder interval. **Fix:** When `_syncInterval` is received from the server, apply it as the default for any new folder timers and update existing ones. --- ## 8. Windows Desktop Readiness | Item | Status | |---|---| | `dart:io` WebSocket | ✅ Works on Windows | | `file_selector` | ✅ Has Windows support | | `watcher` | ✅ Uses native FS events | | `sqflite` + FFI | ⚠️ Needs `sqflite_common_ffi` + Windows SQLite DLL | | `Process.run('open', [...])` | ❌ macOS only – all `openInFinder`, `_openUrl`, `_ensureStorageBaseExists` use `open` command | | `Platform.environment['HOME']` | ⚠️ On Windows use `Platform.environment['USERPROFILE']` | | Sandbox / path permissions | ✅ Windows has fewer restrictions than macOS | **Recommended abstraction:** ```dart String getHomeDir() { if (Platform.isMacOS || Platform.isLinux) { return Platform.environment['HOME'] ?? Directory.current.path; } else if (Platform.isWindows) { return Platform.environment['USERPROFILE'] ?? Platform.environment['HOMEPATH'] ?? 'C:\\Users\\Default'; } return Directory.current.path; } Future openInExplorer(String path) async { if (Platform.isMacOS) { await Process.run('open', ['-R', path]); } else if (Platform.isWindows) { await Process.run('explorer', ['/select,', path]); } else { await Process.run('xdg-open', [p.dirname(path)]); } } ``` --- ## 9. Summary Table | # | Severity | Category | Issue | |---|---|---|---| | 7.1 | 🔴 Critical | Architecture | `unique_id` hardcoded – breaks multi-machine | | 7.2 | 🔴 Critical | Platform | `sqflite` won't work on macOS/Windows desktop without FFI | | 7.3 | 🔴 Critical | Logic | `_uploadFile` ignores `relativePath` param | | 7.4 | 🔴 Critical | Logic | ZIP mtime is DOS format, not Unix – skip-overwrite logic wrong | | 7.5 | 🔴 Critical | Stability | `_ws!.add` without null/open guard → potential crash | | 7.6 | 🟠 Bug | Stability | Double reconnect scheduling, exponential reconnect storms | | 7.7 | 🟠 Bug | Logic | `_handleAuthRevoked` leaks per-folder timers | | 7.8 | 🟠 Bug | Performance | Auth polling sends WS validate during pending phase (noise) | | 7.9 | 🟠 Bug | Logic | `_primeKnownKeys` expects List, gets Map → always empty | | 7.10 | 🟠 Bug | Logic | Role-based folder filtering skipped when WS is connected | | 7.11 | 🟠 Bug | Logic | Two overlapping periodic sync timers cause race conditions | | 7.12 | 🟡 Quality | State | `_knownS3Keys` lost on restart → all files re-uploaded as new | | 7.13 | 🟡 Quality | Deps | `http` and `web_socket_channel` are unused dependencies | | 7.14 | 🟡 Quality | UX | WS not auto-connected on startup with saved auth | | 7.15 | 🟡 Quality | Feature | `guessContentType` missing most media file types | | 7.16 | 🟡 Quality | UX | Login screen WS indicator always red at launch | | 7.17 | 🟡 Quality | Architecture | All state in root `MyApp` widget (anti-pattern) | | 7.18 | 🟡 Quality | Security | Auth key stored as plaintext file | | 7.19 | 🟡 Quality | Stability | `setState` after dispose possible in async loaders | | 7.20 | 🟡 Quality | Feature | Backend sync interval not applied to per-folder timers | | W.1 | 🟠 Platform | Windows | All `Process.run('open', ...)` calls are macOS-only | | W.2 | 🟠 Platform | Windows | `HOME` env var not set on Windows – use `USERPROFILE` |