12 KiB
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)
- After successful auth,
fetchFileList()is called. - App fetches filtered folders list:
GET /planxo/folders/filtered(HTTP, withX-Auth-Keyheader). - For each folder in the list,
_downloadAndExtractFolder(folder)is called:GET /folders/download?folder=<encoded>→ returns a ZIP stream.- ZIP is decoded with
archivepackage. - 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
DirectoryWatcheris started on the local folder.
- Periodic sync (per-folder timer or global timer) calls
_downloadAndExtractFolderagain to check for remote changes.
4. Sync Logic (Upload Path)
DirectoryWatcherfiresADDorMODIFYevents._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
_syncMetaand persists to~/.planxo_sync_meta.json.
- 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:
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<void> 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 |