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 @@