From 6d24eee30f43ac59831c02ece2ba14585cdb85b7 Mon Sep 17 00:00:00 2001 From: MemeTactician Date: Wed, 6 May 2026 16:43:05 -0300 Subject: [PATCH 01/12] feat: implement villager privacy controls with 5s hover-lock and click-to-unlock --- app.go | 2 + app_settings.go | 2 + frontend/src/lib/components/Sidebar.svelte | 217 +++++++++++++++++---- frontend/src/lib/stores/appState.js | 4 + 4 files changed, 192 insertions(+), 33 deletions(-) diff --git a/app.go b/app.go index a48a559..f97420d 100644 --- a/app.go +++ b/app.go @@ -104,6 +104,8 @@ func NewApp() *App { "allow_github_check": true, // Allow pinging GitHub for derod updates "hide_balance": false, "hide_address": false, + "avatar_hidden": false, + "privacy_mode": false, }, history: make([]string, 0), consoleLogs: make([]ConsoleLog, 0), diff --git a/app_settings.go b/app_settings.go index ed51b03..5e86c96 100644 --- a/app_settings.go +++ b/app_settings.go @@ -30,6 +30,8 @@ var persistedSettingKeys = []string{ "epoch_enabled", "hide_balance", "hide_address", + "avatar_hidden", + "privacy_mode", } // Settings Functions diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 36bdc9b..01f0758 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -1,6 +1,6 @@ ` +} + // getXSWDBridgeScript returns the XSWD bridge script to inject into TELA apps // This script intercepts WebSocket connections to localhost:44326 and routes them // through Hologram's XSWD server via postMessage From 059efec8ab32d15105ea482c2e8a68f0536f150c Mon Sep 17 00:00:00 2001 From: dhebp <152355273+DHEBP@users.noreply.github.com> Date: Fri, 15 May 2026 21:36:31 -0400 Subject: [PATCH 05/12] fix(wallet): live dashboard refresh after incoming transactions Wallet dashboard previously waited on a 15s balance-only poll, so a received payment did not appear in Recent Activity or History until the user clicked Refresh. Introduce a three-tier refresh strategy: - Push: subscribe to XSWD wallet:newTransaction and wallet:balanceChanged events so credits surface within a tick of the daemon emission. - Pulse: 5s heartbeat reads cached balance and sync status; transaction history reloads only when the wallet height has actually advanced (Engram-style height-bounded scan) to avoid wasted work on quiet ticks. - Force: manual Refresh and wallet-open paths still run SyncWallet for on-demand freshness, routed through the same helper. Concurrent refreshes are coalesced via an activeRefresh promise; a forceSync caller can upgrade a piggybacked cheap refresh. New incoming or coinbase transactions trigger a toast. Reset lastSyncedWalletHeight and syncStatus on close so a subsequently opened wallet at a lower height still trips the height-advance gate. --- frontend/src/routes/Wallet.svelte | 109 +++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 17 deletions(-) diff --git a/frontend/src/routes/Wallet.svelte b/frontend/src/routes/Wallet.svelte index d36d7c7..55c36c6 100644 --- a/frontend/src/routes/Wallet.svelte +++ b/frontend/src/routes/Wallet.svelte @@ -283,14 +283,30 @@ // ============================================ // POLLING // ============================================ - let balanceInterval; - + // Heartbeat is intentionally tight (Engram-style "pulse") so the dashboard + // feels live. Per-tick work is three in-memory walletapi reads -- balance, + // height, daemon height -- plus a transaction-history reload only when the + // wallet height has actually advanced. Push channels (wallet:newTransaction, + // wallet:balanceChanged) supplement this when XSWD is subscribed. A full + // SyncWallet is reserved for explicit user actions (manual Refresh, wallet + // open, registration complete) where the user is waiting on freshness. + const WALLET_REFRESH_INTERVAL_MS = 5000; + let refreshInterval; + let activeRefresh = null; + let lastSyncedWalletHeight = 0; + function startPolling() { - balanceInterval = setInterval(refreshBalance, 15000); + stopPolling(); + refreshInterval = setInterval(() => { + refreshWalletSnapshot({ notifyIncoming: true }); + }, WALLET_REFRESH_INTERVAL_MS); } - + function stopPolling() { - if (balanceInterval) clearInterval(balanceInterval); + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } } // ============================================ @@ -307,8 +323,7 @@ walletPath: status.path, })); walletPath = status.path || ''; - await refreshBalance(); - await loadTransactionHistory(); + await refreshWalletSnapshot({ forceSync: true }); dashboardLoading = false; startPolling(); loadWalletPath(); @@ -339,6 +354,14 @@ EventsOn('wallet:daemon_connection_warning', (data) => { console.warn('[WALLET] Daemon connection warning:', data); }); + + EventsOn('wallet:newTransaction', () => { + refreshWalletSnapshot({ forceHistory: true, notifyIncoming: true }); + }); + + EventsOn('wallet:balanceChanged', () => { + refreshWalletSnapshot(); + }); // Listen for wallet unregistered status (new wallets) EventsOn('wallet:unregistered', (data) => { @@ -443,6 +466,8 @@ EventsOff('network-mode-changed'); EventsOff('wallet:sync_warning'); EventsOff('wallet:daemon_connection_warning'); + EventsOff('wallet:newTransaction'); + EventsOff('wallet:balanceChanged'); EventsOff('wallet:unregistered'); EventsOff('wallet:registration_started'); EventsOff('wallet:registration_progress'); @@ -575,8 +600,7 @@ address: result.address, walletPath: result.path, })); - await refreshBalance(); - await loadTransactionHistory(); + await refreshWalletSnapshot({ forceSync: true }); startPolling(); loadWalletPath(); SubscribeToWalletEvents().catch(() => {}); @@ -663,6 +687,59 @@ isSyncing = false; } } + + // Coalesces concurrent refreshes. A cheap refresh piggybacks on any + // in-flight refresh; a forceSync or forceHistory refresh waits for the + // in-flight one and then runs a fresh pass so manual Refresh and push + // events always reflect daemon state. + // + // History reload is gated on real signals (height advance, push event, or + // explicit force) instead of firing every tick. This matches Engram's + // height-bounded Show_Transfers pattern: when no block has been seen, no + // tx can have arrived, so re-fetching history is wasted work. + async function refreshWalletSnapshot({ + forceSync = false, + forceHistory = false, + notifyIncoming = false, + } = {}) { + if (!$walletState.isOpen) return; + + if (activeRefresh) { + await activeRefresh; + if (!forceSync && !forceHistory) return; + } + + const previousLatestTxId = transactionHistory[0]?.txid || null; + + activeRefresh = (async () => { + try { + await refreshBalance(forceSync); + + const newHeight = syncStatus?.walletHeight ?? 0; + const heightAdvanced = newHeight > lastSyncedWalletHeight; + + if (forceSync || forceHistory || heightAdvanced) { + await loadTransactionHistory(); + if (newHeight > 0) lastSyncedWalletHeight = newHeight; + } + + const latestTx = transactionHistory[0]; + if ( + notifyIncoming && + previousLatestTxId && + latestTx && + latestTx.txid !== previousLatestTxId && + (latestTx.incoming || latestTx.coinbase) + ) { + toast.success(`Received ${formatBalance(latestTx.amount)} DERO`); + } + } finally { + activeRefresh = null; + } + })(); + + await activeRefresh; + } async function loadTransactionHistory(limit = historyLimit) { try { @@ -709,11 +786,9 @@ async function refreshAll() { - // Force sync when user manually refreshes - await refreshBalance(true); - await loadTransactionHistory(); + await refreshWalletSnapshot({ forceSync: true }); await checkRegistrationStatus(); - + if (syncStatus && !syncStatus.synced && syncStatus.behindBlocks > 0) { toast.info(`Syncing: ${syncStatus.behindBlocks} blocks behind`); } else { @@ -850,8 +925,7 @@ toast.warning(result.networkWarning); } - await refreshBalance(); - await loadTransactionHistory(); + await refreshWalletSnapshot({ forceSync: true }); await checkRegistrationStatus(); dashboardLoading = false; startPolling(); @@ -884,6 +958,8 @@ walletPath: '', })); transactionHistory = []; + lastSyncedWalletHeight = 0; + syncStatus = null; activeSection = 'dashboard'; addressType = 'standard'; integratedAddress = ''; @@ -1080,8 +1156,7 @@ sendTxid = result.result?.txid || result.txid; sendStep = 3; toast.success('Transaction sent successfully!'); - await refreshBalance(); - await loadTransactionHistory(); + await refreshWalletSnapshot(); } else { sendError = handleBackendError(result, { showToast: false }) || 'Transaction failed'; } From 4f8996746764b45796ca2ada88dbf1c6cabf10cc Mon Sep 17 00:00:00 2001 From: dhebp <152355273+DHEBP@users.noreply.github.com> Date: Tue, 19 May 2026 10:08:41 -0400 Subject: [PATCH 06/12] fix(studio): restore Linux native drag-and-drop in Studio Wails OnFileDrop was missing drops on Linux due to DPI/coordinate mismatch with useDropTarget, stopPropagation blocking drag handlers, and only using paths[0] for multi-item file manager drops. - Add ResolveDropPaths and LoadFilesFromPaths for folder/file resolution - Hit-test drop zones with device-pixel to CSS coord conversion - Wire Install DOC, Batch Upload, and DocShards to native drops - preventDefault only on file drag events (wails#3686) --- file_service.go | 159 ++++++++++++++++++ file_service_test.go | 40 +++++ frontend/src/App.svelte | 15 +- .../studio/StudioBatchUpload.svelte | 8 +- .../components/studio/StudioInstallDoc.svelte | 7 +- frontend/src/lib/utils/fileDrop.js | 36 ++++ frontend/src/routes/Studio.svelte | 122 +++++++++----- frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 + 9 files changed, 340 insertions(+), 59 deletions(-) create mode 100644 frontend/src/lib/utils/fileDrop.js diff --git a/file_service.go b/file_service.go index b7a5392..3f33a97 100644 --- a/file_service.go +++ b/file_service.go @@ -817,6 +817,165 @@ func (a *App) SelectFiles() map[string]interface{} { } } +// LoadFilesFromPaths reads dropped or resolved filesystem paths into the same shape as SelectFiles. +func (a *App) LoadFilesFromPaths(paths []string) map[string]interface{} { + if len(paths) == 0 { + return map[string]interface{}{ + "success": false, + "error": "No paths provided", + } + } + + var files []map[string]interface{} + for _, filePath := range paths { + info, err := os.Stat(filePath) + if err != nil { + a.logToConsole(fmt.Sprintf("[WARN] LoadFilesFromPaths: Could not stat %s - %v", filePath, err)) + continue + } + if info.IsDir() { + continue + } + + content, err := os.ReadFile(filePath) + if err != nil { + a.logToConsole(fmt.Sprintf("[WARN] LoadFilesFromPaths: Could not read %s - %v", filePath, err)) + continue + } + + files = append(files, map[string]interface{}{ + "name": info.Name(), + "path": filePath, + "subDir": "/", + "size": info.Size(), + "type": detectMimeType(info.Name()), + "data": string(content), + }) + } + + if len(files) == 0 { + return map[string]interface{}{ + "success": false, + "error": "No readable files in drop", + } + } + + return map[string]interface{}{ + "success": true, + "files": files, + } +} + +// ResolveDropPaths picks a folder path suitable for batch upload from native file-drop paths. +func (a *App) ResolveDropPaths(paths []string) map[string]interface{} { + if len(paths) == 0 { + return map[string]interface{}{ + "success": false, + "error": "No paths provided", + } + } + + absPaths := make([]string, 0, len(paths)) + for _, p := range paths { + p = strings.TrimSpace(p) + if p == "" { + continue + } + abs, err := filepath.Abs(p) + if err != nil { + continue + } + absPaths = append(absPaths, abs) + } + + if len(absPaths) == 0 { + return map[string]interface{}{ + "success": false, + "error": "No valid paths in drop", + } + } + + if len(absPaths) == 1 { + info, err := os.Stat(absPaths[0]) + if err != nil { + return map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("Cannot access path: %v", err), + } + } + folder := absPaths[0] + if !info.IsDir() { + folder = filepath.Dir(absPaths[0]) + } + return map[string]interface{}{ + "success": true, + "folderPath": folder, + "paths": paths, + } + } + + dirs := make([]string, len(absPaths)) + for i, p := range absPaths { + info, err := os.Stat(p) + if err != nil { + return map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("Cannot access %s: %v", p, err), + } + } + if info.IsDir() { + dirs[i] = p + } else { + dirs[i] = filepath.Dir(p) + } + } + + folder := dirs[0] + for _, d := range dirs[1:] { + folder = longestCommonPathPrefix(folder, d) + if folder == "" { + break + } + } + if folder == "" { + folder = dirs[0] + } + + return map[string]interface{}{ + "success": true, + "folderPath": folder, + "paths": paths, + } +} + +// longestCommonPathPrefix returns the longest shared directory prefix for two absolute paths. +func longestCommonPathPrefix(a, b string) string { + a = filepath.Clean(a) + b = filepath.Clean(b) + if a == b { + return a + } + + sep := string(filepath.Separator) + aParts := strings.Split(a, sep) + bParts := strings.Split(b, sep) + common := make([]string, 0, len(aParts)) + for i := 0; i < len(aParts) && i < len(bParts); i++ { + if aParts[i] != bParts[i] { + break + } + common = append(common, aParts[i]) + } + if len(common) == 0 { + return "" + } + prefix := filepath.Join(common...) + if strings.HasPrefix(a, sep) && !strings.HasPrefix(prefix, sep) { + prefix = sep + prefix + } + return prefix +} + // detectMimeType returns the MIME type based on file extension func detectMimeType(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) diff --git a/file_service_test.go b/file_service_test.go index 8bd694b..d72a090 100644 --- a/file_service_test.go +++ b/file_service_test.go @@ -992,3 +992,43 @@ func TestJSONRoundTrip_VirtualShardDOCInfo(t *testing.T) { } } +func TestLongestCommonPathPrefix(t *testing.T) { + a := "/home/user/project/static/index.html" + b := "/home/user/project/static/assets/icon.png" + got := longestCommonPathPrefix(a, b) + want := "/home/user/project/static" + if got != want { + t.Errorf("longestCommonPathPrefix() = %q, want %q", got, want) + } +} + +func TestResolveDropPaths_MultiFile(t *testing.T) { + app := &App{} + dir := t.TempDir() + sub := filepath.Join(dir, "static") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + html := filepath.Join(sub, "index.html") + if err := os.WriteFile(html, []byte(""), 0644); err != nil { + t.Fatal(err) + } + assetDir := filepath.Join(sub, "assets") + if err := os.MkdirAll(assetDir, 0755); err != nil { + t.Fatal(err) + } + ico := filepath.Join(assetDir, "favicon.ico") + if err := os.WriteFile(ico, []byte("ico"), 0644); err != nil { + t.Fatal(err) + } + + result := app.ResolveDropPaths([]string{html, ico}) + if result["success"] != true { + t.Fatalf("ResolveDropPaths failed: %v", result["error"]) + } + folder, _ := result["folderPath"].(string) + if folder != sub { + t.Errorf("folderPath = %q, want %q", folder, sub) + } +} + diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 16ea606..320186a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -116,17 +116,16 @@ }; window.addEventListener('focus', handleWindowFocus); - // Secondary defense against webview navigating to dropped files. - // Primary fix is DisableWebViewDrop: true in main.go (Go/native level). - // This JS layer catches any edge cases the native flag might miss. - const preventFileDrop = (e) => { - if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + // Prevent WebKit from navigating to dropped files (Linux #3686). + // Only preventDefault — do not stopPropagation, so Wails drag listeners still run. + const preventFileDropNavigation = (e) => { + if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); - e.stopPropagation(); } }; - window.addEventListener('dragover', preventFileDrop, true); - window.addEventListener('drop', preventFileDrop, true); + for (const evtName of ['dragover', 'drop', 'dragleave', 'dragenter']) { + window.addEventListener(evtName, preventFileDropNavigation, true); + } // Minimum splash duration (allows animation to complete) const splashMinTime = new Promise(resolve => setTimeout(resolve, 3500)); diff --git a/frontend/src/lib/components/studio/StudioBatchUpload.svelte b/frontend/src/lib/components/studio/StudioBatchUpload.svelte index 886d4d7..e0f939b 100644 --- a/frontend/src/lib/components/studio/StudioBatchUpload.svelte +++ b/frontend/src/lib/components/studio/StudioBatchUpload.svelte @@ -20,11 +20,9 @@ bind:this={batchDropzoneElement} class="dropzone" class:active={batchDragging} - on:dragover|preventDefault={() => batchDragging = true} - on:dragleave={() => batchDragging = false} - on:drop|preventDefault={() => { - batchDragging = false; - }} + on:dragover|preventDefault={() => { batchDragging = true; }} + on:dragleave={() => { batchDragging = false; }} + on:drop|preventDefault={() => { batchDragging = false; }} on:click={async () => { const selected = await selectFolder(); if (selected) { diff --git a/frontend/src/lib/components/studio/StudioInstallDoc.svelte b/frontend/src/lib/components/studio/StudioInstallDoc.svelte index ec597f6..0896ebd 100644 --- a/frontend/src/lib/components/studio/StudioInstallDoc.svelte +++ b/frontend/src/lib/components/studio/StudioInstallDoc.svelte @@ -15,6 +15,7 @@ X } from 'lucide-svelte'; + export let dropzoneElement = null; export let stagedFiles = []; export let docDescription = ''; export let docIconURL = ''; @@ -49,8 +50,10 @@

- - + +
+ +
{#if stagedFiles.length > 0} diff --git a/frontend/src/lib/utils/fileDrop.js b/frontend/src/lib/utils/fileDrop.js new file mode 100644 index 0000000..e09853d --- /dev/null +++ b/frontend/src/lib/utils/fileDrop.js @@ -0,0 +1,36 @@ +/** + * Helpers for Wails native OnFileDrop (Linux GTK coords vs CSS layout). + * See https://github.com/wailsapp/wails/issues/3686 + */ + +/** + * Convert native drop coordinates to CSS pixels for getBoundingClientRect(). + * GTK/Wails may supply device pixels; layout uses CSS pixels. + */ +export function dropCoordsToCSS(x, y) { + const dpr = window.devicePixelRatio || 1; + if (dpr <= 1) { + return { x, y }; + } + return { x: x / dpr, y: y / dpr }; +} + +/** + * True if native drop coordinates fall inside element's bounding box. + * Tries CSS-scaled coords first, then raw coords (platform variance). + */ +export function isDropPointInElement(x, y, element) { + if (!element) { + return false; + } + const rect = element.getBoundingClientRect(); + const css = dropCoordsToCSS(x, y); + if (pointInRect(css.x, css.y, rect)) { + return true; + } + return pointInRect(x, y, rect); +} + +function pointInRect(x, y, rect) { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} diff --git a/frontend/src/routes/Studio.svelte b/frontend/src/routes/Studio.svelte index 260383e..0c0ba99 100644 --- a/frontend/src/routes/Studio.svelte +++ b/frontend/src/routes/Studio.svelte @@ -1,5 +1,5 @@