diff --git a/electron/closeBehavior.js b/electron/closeBehavior.js new file mode 100644 index 00000000..a5ac1839 --- /dev/null +++ b/electron/closeBehavior.js @@ -0,0 +1,121 @@ +const fs = require("fs"); +const path = require("node:path"); + +const CLOSE_BEHAVIORS = new Set(["ask", "quit", "background"]); + +function defaultCloseSettings() { + return { + closeBehavior: "ask", + trayEnabled: true, + }; +} + +function closeSettingsPath(storageDir) { + return path.join(storageDir, "desktop-close-settings.json"); +} + +function normalizeCloseSettings(raw) { + const defaults = defaultCloseSettings(); + if (!raw || typeof raw !== "object") { + return defaults; + } + const closeBehavior = CLOSE_BEHAVIORS.has(raw.closeBehavior) ? raw.closeBehavior : defaults.closeBehavior; + const trayEnabled = typeof raw.trayEnabled === "boolean" ? raw.trayEnabled : defaults.trayEnabled; + return { closeBehavior, trayEnabled }; +} + +function loadCloseSettings(storageDir) { + try { + const filePath = closeSettingsPath(storageDir); + if (!fs.existsSync(filePath)) { + return defaultCloseSettings(); + } + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + return normalizeCloseSettings(parsed); + } catch { + return defaultCloseSettings(); + } +} + +function saveCloseSettings(storageDir, partial) { + const current = loadCloseSettings(storageDir); + const next = normalizeCloseSettings({ + ...current, + ...(partial && typeof partial === "object" ? partial : {}), + }); + try { + fs.mkdirSync(storageDir, { recursive: true }); + fs.writeFileSync(closeSettingsPath(storageDir), JSON.stringify(next, null, 2), "utf8"); + } catch { + // ignore persistence failures; in-memory choice still applies for this session + } + return next; +} + +/** + * Resolve what to do on window close. + * @returns {"ask"|"quit"|"background"|"minimize"} + */ +function resolveCloseAction(settings) { + const normalized = normalizeCloseSettings(settings); + if (normalized.closeBehavior === "ask") { + return "ask"; + } + if (normalized.closeBehavior === "quit") { + return "quit"; + } + if (!normalized.trayEnabled) { + return "minimize"; + } + return "background"; +} + +/** + * Map a chosen close action + remember checkbox into persisted settings. + * Non-quit choices are stored as "background" and resolved to minimize when tray is off. + * @param {"quit"|"background"|"minimize"} action + * @param {boolean} remember + * @returns {{ closeBehavior: "quit"|"background" }|null} + */ +function rememberedCloseSettings(action, remember) { + if (!remember) { + return null; + } + if (action === "quit") { + return { closeBehavior: "quit" }; + } + if (action === "background" || action === "minimize") { + return { closeBehavior: "background" }; + } + return null; +} + +/** + * Simple re-entrancy guard for async close handling. + */ +function createCloseRequestGuard() { + let inFlight = false; + return { + tryEnter() { + if (inFlight) { + return false; + } + inFlight = true; + return true; + }, + leave() { + inFlight = false; + }, + }; +} + +module.exports = { + CLOSE_BEHAVIORS, + defaultCloseSettings, + loadCloseSettings, + saveCloseSettings, + resolveCloseAction, + normalizeCloseSettings, + rememberedCloseSettings, + createCloseRequestGuard, +}; diff --git a/electron/main.js b/electron/main.js index 4066dae1..0c759012 100644 --- a/electron/main.js +++ b/electron/main.js @@ -27,9 +27,18 @@ const { } = require("./mainHelpers"); const { isAllowedShellPath } = require("./shellPathGuard"); const { normalizeExternalUrlForOpen } = require("./safeExternalUrl"); +const { + loadCloseSettings, + saveCloseSettings, + resolveCloseAction, + rememberedCloseSettings, + createCloseRequestGuard, +} = require("./closeBehavior"); // remember main window var mainWindow = null; +var closeSettings = null; +var closeRequestGuard = createCloseRequestGuard(); function getDialogParentWindow() { const focused = BrowserWindow.getFocusedWindow(); @@ -339,6 +348,14 @@ ipcMain.handle("shutdown", () => { quit(); }); +ipcMain.handle("get-close-settings", () => { + return getCloseSettings(); +}); + +ipcMain.handle("set-close-settings", (_event, partial) => { + return updateCloseSettings(partial || {}); +}); + ipcMain.handle("get-memory-usage", async () => { return process.getProcessMemoryInfo(); }); @@ -648,7 +665,30 @@ function getBackendManager() { return backendManager; } +function getCloseSettings() { + if (!closeSettings) { + closeSettings = loadCloseSettings(getDefaultStorageDir()); + } + return closeSettings; +} + +function updateCloseSettings(partial) { + closeSettings = saveCloseSettings(getDefaultStorageDir(), partial); + syncTrayWithSettings(); + return closeSettings; +} + +function destroyTray() { + if (tray && !tray.isDestroyed()) { + tray.destroy(); + } + tray = null; +} + function createTray() { + if (tray && !tray.isDestroyed()) { + return; + } tray = new Tray(getAppIconPath()); const contextMenu = Menu.buildFromTemplate([ { @@ -682,6 +722,78 @@ function createTray() { }); } +function syncTrayWithSettings() { + const settings = getCloseSettings(); + if (settings.trayEnabled) { + createTray(); + } else { + destroyTray(); + } +} + +async function promptCloseAction() { + const settings = getCloseSettings(); + const backgroundLabel = settings.trayEnabled ? "Keep running in background" : "Minimize to taskbar"; + const backgroundDetail = settings.trayEnabled + ? "Hide the window and keep MeshChatX in the system tray." + : "Minimize MeshChatX to the taskbar."; + const result = await dialog.showMessageBox(getDialogParentWindow() || undefined, { + type: "question", + title: "Close MeshChatX?", + message: "Close MeshChatX?", + detail: `Choose whether to quit the application or keep it running.\n\n${backgroundDetail}`, + buttons: ["Cancel", "Quit application", backgroundLabel], + defaultId: 2, + cancelId: 0, + checkboxLabel: "Remember my choice", + checkboxChecked: false, + }); + if (result.response === 0) { + return null; + } + const action = result.response === 1 ? "quit" : settings.trayEnabled ? "background" : "minimize"; + const remembered = rememberedCloseSettings(action, result.checkboxChecked); + if (remembered) { + updateCloseSettings(remembered); + } + return action; +} + +async function handleWindowCloseRequest(event) { + if (isQuiting) { + return; + } + event.preventDefault(); + if (!closeRequestGuard.tryEnter()) { + return; + } + try { + const settings = getCloseSettings(); + let action = resolveCloseAction(settings); + if (action === "ask") { + action = await promptCloseAction(); + if (!action) { + return; + } + } + if (action === "quit") { + isQuiting = true; + quit(); + return; + } + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + if (action === "minimize") { + mainWindow.minimize(); + return; + } + mainWindow.hide(); + } finally { + closeRequestGuard.leave(); + } +} + app.whenReady().then(async () => { app.on("browser-window-created", (event, browserWindow) => { attachDefaultContextMenu(browserWindow); @@ -721,8 +833,8 @@ app.whenReady().then(async () => { const isHardwareAccelerationEnabled = app.isHardwareAccelerationEnabled(); log(`Hardware Acceleration Enabled: ${isHardwareAccelerationEnabled}`); - // Create system tray - createTray(); + // Create system tray when enabled in desktop close settings + syncTrayWithSettings(); // get arguments passed to application, and remove the provided application path const userProvidedArguments = getUserProvidedArguments(process.argv); @@ -779,13 +891,9 @@ app.whenReady().then(async () => { } ); - // minimize to tray behavior + // quit / minimize / hide-to-tray based on remembered close settings mainWindow.on("close", (event) => { - if (!isQuiting) { - event.preventDefault(); - mainWindow.hide(); - return false; - } + void handleWindowCloseRequest(event); }); // navigate to loading page @@ -938,10 +1046,7 @@ app.on("before-quit", () => { isQuiting = true; } // Ensure tray is destroyed to prevent it from keeping the app alive - if (tray && !tray.isDestroyed()) { - tray.destroy(); - tray = null; - } + destroyTray(); }); // quit electron if all windows are closed diff --git a/electron/preload.js b/electron/preload.js index 09bcee15..7e90f896 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -70,6 +70,14 @@ contextBridge.exposeInMainWorld("electron", { return await ipcRenderer.invoke("shutdown"); }, + getCloseSettings: async function () { + return await ipcRenderer.invoke("get-close-settings"); + }, + + setCloseSettings: async function (partial) { + return await ipcRenderer.invoke("set-close-settings", partial); + }, + // allow getting memory usage in electron browser window getMemoryUsage: async function () { return await ipcRenderer.invoke("get-memory-usage"); diff --git a/meshchatx/src/backend/database/announces.py b/meshchatx/src/backend/database/announces.py index 289cc861..066b25ac 100644 --- a/meshchatx/src/backend/database/announces.py +++ b/meshchatx/src/backend/database/announces.py @@ -187,14 +187,32 @@ class AnnounceDAO: # Favourites def upsert_favourite(self, destination_hash, display_name, aspect): + from meshchatx.src.backend.favourite_display_names import ( + is_unknown_favourite_display_name, + ) + now = datetime.now(UTC) + preserve_unknown = is_unknown_favourite_display_name(display_name) self.provider.execute( """ INSERT INTO favourite_destinations (destination_hash, display_name, aspect, created_at, updated_at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, aspect = EXCLUDED.aspect, updated_at = EXCLUDED.updated_at + ON CONFLICT(destination_hash) DO UPDATE SET + display_name = CASE + WHEN ? THEN favourite_destinations.display_name + ELSE EXCLUDED.display_name + END, + aspect = EXCLUDED.aspect, + updated_at = EXCLUDED.updated_at """, - (destination_hash, display_name, aspect, now, now), + ( + destination_hash, + display_name, + aspect, + now, + now, + 1 if preserve_unknown else 0, + ), ) def get_favourite_by_destination_hash(self, destination_hash): diff --git a/meshchatx/src/backend/favourite_display_names.py b/meshchatx/src/backend/favourite_display_names.py new file mode 100644 index 00000000..5c77b3c3 --- /dev/null +++ b/meshchatx/src/backend/favourite_display_names.py @@ -0,0 +1,25 @@ +"""Shared favourite display-name sentinels that must not clobber stored names.""" + +# Keep in sync with meshchatx/src/frontend/js/nomadUnknownNodeName.js +UNKNOWN_FAVOURITE_NAMES = frozenset( + { + "", + "Unknown Node", + "Anonymous Node", + "Unbekannter Knoten", + "Nodo desconocido", + "Tuntematon solmu", + "Noeud inconnu", + "Nodo Sconosciuto", + "Onbekende knoop", + "Неизвестный узел", + "未知节点", + } +) + + +def is_unknown_favourite_display_name(name) -> bool: + """Return True when ``name`` is empty or a known unknown-node placeholder.""" + if not isinstance(name, str): + return True + return name.strip() in UNKNOWN_FAVOURITE_NAMES diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index 8edd786e..68846141 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -621,6 +621,7 @@ import { } from "../../js/MicronWasmLoader"; import { VTooltip } from "vuetify/components/VTooltip"; import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore"; +import { isUnknownNodeDisplayName, resolveFavouriteUpsertDisplayName } from "../../js/nomadUnknownNodeName.js"; export default { name: "NomadNetworkPage", @@ -1071,15 +1072,7 @@ export default { (async () => { await this.getNomadnetworkNodeAnnounce(bootstrapHash); - if (this.nodes[bootstrapHash]) { - this.selectedNode = this.nodes[bootstrapHash]; - } else { - this.selectedNode = { - destination_hash: bootstrapHash, - display_name: "Unknown Node", - aspect: "nomadnetwork.node", - }; - } + this.selectedNode = this.resolveNodeForHash(bootstrapHash); this.getNodePath(bootstrapHash); @@ -1114,11 +1107,7 @@ export default { } try { await this.getNomadnetworkNodeAnnounce(hash); - this.selectedNode = this.nodes[hash] || { - destination_hash: hash, - display_name: this.$t("nomadnet.unknown_node"), - aspect: "nomadnetwork.node", - }; + this.selectedNode = this.resolveNodeForHash(hash); const path = typeof pagePath === "string" && pagePath.length > 0 ? pagePath : this.defaultNodePagePath; await this.loadNodePage(hash, path, null, false, true); } catch (e) { @@ -1584,6 +1573,48 @@ export default { console.log(e); } }, + isUnknownNodeName(name) { + return isUnknownNodeDisplayName(name, this.$t("nomadnet.unknown_node")); + }, + resolveNodeForHash(destinationHash) { + const hash = (destinationHash || "").trim(); + if (!hash) { + return null; + } + const cached = this.nodes[hash]; + const favourite = this.favourites.find((f) => f.destination_hash === hash); + const favouriteName = favourite?.custom_display_name || favourite?.display_name || ""; + if (cached) { + const cachedName = cached.custom_display_name || cached.display_name || ""; + if (this.isUnknownNodeName(cachedName) && favouriteName && !this.isUnknownNodeName(favouriteName)) { + return { + ...cached, + display_name: favouriteName, + custom_display_name: favourite?.custom_display_name || favouriteName, + }; + } + return cached; + } + if (favouriteName && !this.isUnknownNodeName(favouriteName)) { + return { + ...favourite, + display_name: favouriteName, + aspect: favourite.aspect || "nomadnetwork.node", + }; + } + const selectedHash = this.selectedNode?.destination_hash; + if (selectedHash && Object.is(selectedHash, hash)) { + const existingName = this.selectedNode.custom_display_name || this.selectedNode.display_name; + if (existingName && !this.isUnknownNodeName(existingName)) { + return this.selectedNode; + } + } + return { + destination_hash: hash, + display_name: this.$t("nomadnet.unknown_node"), + aspect: "nomadnetwork.node", + }; + }, isFavourite(destinationHash) { return ( this.favourites.find((favourite) => { @@ -1593,9 +1624,13 @@ export default { }, async addFavourite(node) { try { + const existing = this.favourites.find( + (favourite) => favourite.destination_hash === node.destination_hash + ); + const displayName = resolveFavouriteUpsertDisplayName(node, existing, this.$t("nomadnet.unknown_node")); await window.api.post("/api/v1/favourites/add", { destination_hash: node.destination_hash, - display_name: node.display_name, + display_name: displayName, aspect: "nomadnetwork.node", }); await this.getFavourites(); @@ -1643,9 +1678,10 @@ export default { continue; } try { + const displayName = resolveFavouriteUpsertDisplayName(node, null, this.$t("nomadnet.unknown_node")); await window.api.post("/api/v1/favourites/add", { destination_hash: node.destination_hash, - display_name: node.display_name, + display_name: displayName, aspect: "nomadnetwork.node", }); added += 1; @@ -2472,10 +2508,7 @@ export default { } // update selected node, so relative urls work correctly when returned by the new node - this.selectedNode = this.nodes[destinationHash] || { - display_name: this.$t("nomadnet.unknown_node"), - destination_hash: destinationHash, - }; + this.selectedNode = this.resolveNodeForHash(destinationHash); // navigate to node page this.loadNodePage(destinationHash, parsedUrl.path, fieldData, addToHistory, useCache, navOptions); @@ -2492,18 +2525,21 @@ export default { return Utils.formatBytesPerSecond(bytesPerSecond); }, onNodeClick: function (node) { - if (this.shouldOpenInNewTab(node.destination_hash, {})) { - this.emitOpenNode( - node.destination_hash, - this.defaultNodePagePath, - node.custom_display_name || node.display_name || null, - { activate: true } - ); + const hash = node?.destination_hash; + const resolved = hash ? this.resolveNodeForHash(hash) : node; + const title = + resolved?.custom_display_name || + resolved?.display_name || + node?.custom_display_name || + node?.display_name || + null; + if (this.shouldOpenInNewTab(hash, {})) { + this.emitOpenNode(hash, this.defaultNodePagePath, title, { activate: true }); return; } - this.selectedNode = node; - this.loadNodePage(node.destination_hash, this.defaultNodePagePath); + this.selectedNode = resolved || node; + this.loadNodePage(hash, this.defaultNodePagePath); }, async onRenameFavourite(favourite) { // ask user for new display name diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue index 667e4e44..27c9f127 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue @@ -57,7 +57,7 @@ ? 'ring-2 ring-blue-500 ring-offset-1 ring-offset-white dark:ring-offset-zinc-950' : 'hover:bg-white/10' " - :title="fav.display_name" + :title="favouriteDisplayName(fav)" @click="onFavouriteClick(fav)" > @@ -348,9 +348,9 @@
- {{ favourite.display_name }} + {{ favouriteDisplayName(favourite) }}
sectionIds.has(id)); const remaining = sanitizedSections .map((section) => section.id) .filter((id) => !filteredOrder.includes(id)); - this.sectionOrder = [...filteredOrder, ...remaining]; + const nextSectionOrder = [...filteredOrder, ...remaining]; const nextFavouritesBySection = {}; sanitizedSections.forEach((section) => { @@ -1234,8 +1237,17 @@ export default { assigned.add(hash); } }); + + const sectionsChanged = JSON.stringify(this.sections) !== JSON.stringify(sanitizedSections); + const orderChanged = JSON.stringify(this.sectionOrder) !== JSON.stringify(nextSectionOrder); + const favouritesChanged = + JSON.stringify(this.favouritesBySection) !== JSON.stringify(nextFavouritesBySection); + this.sections = sanitizedSections; + this.sectionOrder = nextSectionOrder; this.favouritesBySection = nextFavouritesBySection; - this.persistFavouriteLayout(); + if (sectionsChanged || orderChanged || favouritesChanged) { + this.persistFavouriteLayout(); + } }, isBlocked(identityHash) { return this.blockedDestinations.some((b) => b.destination_hash === identityHash); @@ -1243,6 +1255,22 @@ export default { isFavourite(destinationHash) { return this.favourites.some((f) => f.destination_hash === destinationHash); }, + favouriteDisplayName(favourite) { + if (!favourite) { + return ""; + } + const hash = favourite.destination_hash; + const cached = hash ? this.nodes?.[hash] : null; + const cachedName = cached?.custom_display_name || cached?.display_name || ""; + if (cachedName && !isUnknownNodeDisplayName(cachedName, this.$t("nomadnet.unknown_node"))) { + return cachedName; + } + const favouriteName = favourite.custom_display_name || favourite.display_name || ""; + if (favouriteName && !isUnknownNodeDisplayName(favouriteName, this.$t("nomadnet.unknown_node"))) { + return favouriteName; + } + return favouriteName || this.$t("nomadnet.unknown_node"); + }, addFavouriteFromContext() { const node = this.announceContextMenu.node; if (!node) { diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue index 51f13a1f..300f5817 100644 --- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue +++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue @@ -412,7 +412,10 @@ {{ $t("relay_chat.load_previous") }}