From af2cca6d6a235797d9c8c09ad3343439fb091339 Mon Sep 17 00:00:00 2001 From: troublesis Date: Sun, 14 Jun 2026 11:13:00 +1000 Subject: [PATCH 1/2] feat: add localUrl fallback with background reachability probe Introduce an optional per-item localUrl (e.g. a LAN address) that is preferred over the regular url when reachable from the browser. A background probe (default 10s interval, 5s timeout) checks reachability of localUrl and falls back to url when unreachable, enabling seamless remote/LAN switching without manual config changes. - ItemMixin: add effectiveUrl computed + probe lifecycle (mount/destroy) - Item/SubItem: expose localUrl-driven effective url to templates - ConfigSchema: add localUrl, localUrlTimeout, localUrlCheckInterval - docs/configuring.md: document the new properties Closes Lissy93/dashy#1313 --- docs/configuring.md | 3 ++ src/components/LinkItems/Item.vue | 8 ++- src/components/LinkItems/SubItem.vue | 9 ++++ src/mixins/ItemMixin.js | 73 ++++++++++++++++++++++++++-- src/utils/config/ConfigSchema.json | 17 +++++++ 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/docs/configuring.md b/docs/configuring.md index bf1c536991..7517d446b3 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -265,6 +265,9 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)** **`title`** | `string` | Required | The text to display/ title of a given item. Max length `18` **`description`** | `string` | _Optional_ | Additional info about an item, which is shown in the tooltip on hover, or visible on large tiles **`url`** | `string` | _Optional_ | The URL / location of web address for when the item is clicked +**`localUrl`** | `string` | _Optional_ | An alternative URL (e.g. a LAN address) that is preferred whenever it is reachable from your browser. On page load Dashy probes this URL in the background; if it responds, clicking the item opens `localUrl`, otherwise it falls back to `url`. Ideal for local-vs-remote access (Tailscale, VPN, reverse-proxy, etc). The probe runs in the background so it never delays a click +**`localUrlTimeout`** | `number` | _Optional_ | Milliseconds to wait for the `localUrl` reachability probe before giving up and using `url`. Clamped between `300` and `5000`. Defaults to `1500` +**`localUrlCheckInterval`** | `number` | _Optional_ | Seconds between background re-checks of `localUrl`. `0` means only check on page load and when the browser tab regains focus. Clamped to `300` max. Defaults to `0` **`icon`** | `string` | _Optional_ | The icon for a given item. Can be a font-awesome icon, favicon, remote URL or local URL. See [`item.icon`](#sectionicon-and-sectionitemicon) **`target`** | `string` | _Optional_ | The opening method for when the item is clicked, either `newtab`, `sametab`, `modal`, `workspace`, `clipboard`, `top` or `parent`. Where `newtab` will open the link in a new tab, `sametab` will open it in the current tab, and `modal` will open a pop-up modal, `workspace` will open in the Workspace view and `clipboard` will copy the URL to system clipboard (but not launch app). Defaults to `newtab` **`hotkey`** | `number` | _Optional_ | Give frequently opened applications a numeric hotkey, between `0 - 9`. You can then just press that key to launch that application. diff --git a/src/components/LinkItems/Item.vue b/src/components/LinkItems/Item.vue index ae7db11ff1..8c2d8495a5 100644 --- a/src/components/LinkItems/Item.vue +++ b/src/components/LinkItems/Item.vue @@ -5,7 +5,7 @@ @contextmenu.prevent @mouseup.right="openContextMenu" v-longPress="true" - :href="item.url" + :href="effectiveUrl" :target="anchorTarget" :class="`item ${makeClassList}`" v-tooltip="getTooltipOptions()" @@ -218,11 +218,17 @@ export default { this.intervalId = setInterval(this.checkWebsiteStatus, this.statusCheckInterval * 1000); } } + // If an alternative local URL is set, probe its reachability in the background + if (this.hasLocalUrl) { + this.startLocalUrlChecks(); + } }, beforeUnmount() { // Stop periodic ping-check and status-check when item is destroyed (e.g. navigating in multi-page setup) if (this.pingIntervalId) clearInterval(this.pingIntervalId); if (this.intervalId) clearInterval(this.intervalId); + // Stop local-URL probing timers and listeners + this.stopLocalUrlChecks(); }, }; diff --git a/src/components/LinkItems/SubItem.vue b/src/components/LinkItems/SubItem.vue index 3235ec2385..accb1579b3 100644 --- a/src/components/LinkItems/SubItem.vue +++ b/src/components/LinkItems/SubItem.vue @@ -56,6 +56,15 @@ export default { return {}; }, methods: {}, + mounted() { + // If an alternative local URL is set, probe its reachability in the background + if (this.hasLocalUrl) { + this.startLocalUrlChecks(); + } + }, + beforeUnmount() { + this.stopLocalUrlChecks(); + }, }; diff --git a/src/mixins/ItemMixin.js b/src/mixins/ItemMixin.js index 125c3d8a44..91188bf181 100644 --- a/src/mixins/ItemMixin.js +++ b/src/mixins/ItemMixin.js @@ -25,6 +25,9 @@ export default { contextMenuOpen: false, intervalId: undefined, // status-check setInterval() id pingIntervalId: undefined, // ping-check setInterval() id + // Local URL reachability: undefined = not yet probed, true/false = last result + localUrlReachable: undefined, + localUrlIntervalId: undefined, // local-url re-check setInterval() id contextPos: { posX: undefined, posY: undefined, @@ -107,6 +110,32 @@ export default { accumulatedTarget() { return this.item.target || this.appConfig.defaultOpeningMethod || defaultOpeningMethod; }, + /* True if a non-empty alternative local URL has been configured for this item */ + hasLocalUrl() { + return !!(this.item.localUrl && typeof this.item.localUrl === 'string' + && this.item.localUrl.trim()); + }, + /* Timeout (ms) for the local URL reachability probe, clamped to a sane range */ + localUrlProbeTimeout() { + let timeout = this.item.localUrlTimeout; + if (typeof timeout !== 'number' || Number.isNaN(timeout)) timeout = 1500; + if (timeout < 300) timeout = 300; + if (timeout > 5000) timeout = 5000; + return timeout; + }, + /* Interval (seconds) between background re-checks; 0 = only on load + tab focus */ + localUrlCheckInterval() { + let interval = this.item.localUrlCheckInterval; + if (typeof interval !== 'number' || Number.isNaN(interval) || interval < 0) return 0; + if (interval > 300) interval = 300; + return Math.floor(interval); + }, + /* The URL actually used when the item is opened. Prefers the local URL only once it + has been confirmed reachable from the browser, otherwise uses the regular URL. */ + effectiveUrl() { + if (this.hasLocalUrl && this.localUrlReachable === true) return this.item.localUrl; + return this.url || this.item.url; + }, /* Convert config target value, into HTML anchor target attribute */ anchorTarget() { if (this.isEditMode) return '_self'; @@ -122,7 +151,7 @@ export default { /* Get href for anchor, if not in edit mode, or opening in modal/ workspace */ hyperLinkHref() { const nothing = '#'; - const url = this.url || this.item.url || nothing; + const url = this.effectiveUrl || nothing; if (this.isEditMode) return nothing; const noAnchorNeeded = ['modal', 'workspace', 'clipboard', 'newwindow']; return noAnchorNeeded.includes(this.accumulatedTarget) ? nothing : url; @@ -210,9 +239,47 @@ export default { }); } }, + /* Probes the configured local URL from the browser to decide if it's reachable. + Runs in the background (never blocks a click): the result feeds `effectiveUrl`, + which is already bound to the anchor's href by the time the user clicks. + Uses a no-cors fetch so cross-origin LAN services don't trip CORS — we only + care whether the request settles (reachable) or aborts/errors (unreachable). */ + probeLocalUrl() { + if (!this.hasLocalUrl) return; + const target = this.item.localUrl.trim(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.localUrlProbeTimeout); + fetch(target, { + mode: 'no-cors', + cache: 'no-store', + signal: controller.signal, + }) + .then(() => { this.localUrlReachable = true; }) + .catch(() => { this.localUrlReachable = false; }) + .finally(() => clearTimeout(timer)); + }, + /* Starts local-URL probing: once now, on tab re-focus, and optionally on an interval */ + startLocalUrlChecks() { + if (!this.hasLocalUrl) return; + this.probeLocalUrl(); + if (this.localUrlCheckInterval > 0) { + this.localUrlIntervalId = setInterval(this.probeLocalUrl, this.localUrlCheckInterval * 1000); + } + // Re-probe when the tab becomes visible again (e.g. user switched networks) + document.addEventListener('visibilitychange', this.onVisibilityProbe); + }, + /* Re-probe when the page regains visibility, so a network change is picked up */ + onVisibilityProbe() { + if (document.visibilityState === 'visible') this.probeLocalUrl(); + }, + /* Tears down local-URL probing timers and listeners */ + stopLocalUrlChecks() { + if (this.localUrlIntervalId) clearInterval(this.localUrlIntervalId); + document.removeEventListener('visibilitychange', this.onVisibilityProbe); + }, /* Called when an item is clicked, manages the opening of modal & resets the search field */ itemClicked(e) { - const url = this.url || this.item.url; + const url = this.effectiveUrl; if (this.isEditMode) { // If in edit mode, open settings, and don't launch app e.preventDefault(); @@ -247,7 +314,7 @@ export default { }, /* Open item, using specified method */ launchItem(method, link) { - const url = link || this.item.url; + const url = link || this.effectiveUrl; this.contextMenuOpen = false; switch (method) { case 'newtab': diff --git a/src/utils/config/ConfigSchema.json b/src/utils/config/ConfigSchema.json index 393ad42bec..514cac2c75 100644 --- a/src/utils/config/ConfigSchema.json +++ b/src/utils/config/ConfigSchema.json @@ -1055,6 +1055,23 @@ "type": "string", "description": "The destination to navigate to when item is clicked, expressed as a valid URL, IP or hostname" }, + "localUrl": { + "title": "Local URL", + "type": "string", + "description": "Optional alternative URL preferred when it is reachable from your browser (e.g. a LAN address). On load Dashy probes this URL; if it responds, clicking the item opens it, otherwise it falls back to the Service URL. Useful for local-vs-remote access (Tailscale, VPN, etc)" + }, + "localUrlTimeout": { + "title": "Local URL Probe Timeout", + "type": "number", + "default": 1500, + "description": "Milliseconds to wait for the Local URL reachability probe before giving up and using the Service URL. Clamped between 300 and 5000. The probe runs in the background, so it never delays clicks" + }, + "localUrlCheckInterval": { + "title": "Local URL Re-check Interval", + "type": "number", + "default": 0, + "description": "Seconds between background re-checks of the Local URL. 0 = only check on page load and when the tab regains focus. Clamped to 300 max" + }, "displayData": { "title": "Display Data", "type": "object", From a574fa1c1037ef23589d979631cf862ae8e9f0d2 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sun, 14 Jun 2026 14:38:18 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20Small=20tweaks=20to=20the=20loc?= =?UTF-8?q?al=20url=20fallback=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/configuring.md | 6 +++--- src/components/LinkItems/Item.vue | 6 ------ src/components/LinkItems/SubItem.vue | 9 --------- src/mixins/ItemMixin.js | 28 +++++++++++++++++++--------- src/utils/config/ConfigSchema.json | 6 +++--- 5 files changed, 25 insertions(+), 30 deletions(-) diff --git a/docs/configuring.md b/docs/configuring.md index 7517d446b3..87366f5532 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -265,9 +265,9 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)** **`title`** | `string` | Required | The text to display/ title of a given item. Max length `18` **`description`** | `string` | _Optional_ | Additional info about an item, which is shown in the tooltip on hover, or visible on large tiles **`url`** | `string` | _Optional_ | The URL / location of web address for when the item is clicked -**`localUrl`** | `string` | _Optional_ | An alternative URL (e.g. a LAN address) that is preferred whenever it is reachable from your browser. On page load Dashy probes this URL in the background; if it responds, clicking the item opens `localUrl`, otherwise it falls back to `url`. Ideal for local-vs-remote access (Tailscale, VPN, reverse-proxy, etc). The probe runs in the background so it never delays a click -**`localUrlTimeout`** | `number` | _Optional_ | Milliseconds to wait for the `localUrl` reachability probe before giving up and using `url`. Clamped between `300` and `5000`. Defaults to `1500` -**`localUrlCheckInterval`** | `number` | _Optional_ | Seconds between background re-checks of `localUrl`. `0` means only check on page load and when the browser tab regains focus. Clamped to `300` max. Defaults to `0` +**`localUrl`** | `string` | _Optional_ | An alternative URL (e.g. a LAN address) that is preferred whenever it is reachable from your browser +**`localUrlTimeout`** | `number` | _Optional_ | Milliseconds to wait for the `localUrl` reachability probe before giving up and using `url`. Between `300` and `5000`. Defaults to `1500` +**`localUrlCheckInterval`** | `number` | _Optional_ | Seconds between background re-checks of `localUrl`. `0` means only check on page load and when the browser tab regains focus. `300` max. Defaults to `0` **`icon`** | `string` | _Optional_ | The icon for a given item. Can be a font-awesome icon, favicon, remote URL or local URL. See [`item.icon`](#sectionicon-and-sectionitemicon) **`target`** | `string` | _Optional_ | The opening method for when the item is clicked, either `newtab`, `sametab`, `modal`, `workspace`, `clipboard`, `top` or `parent`. Where `newtab` will open the link in a new tab, `sametab` will open it in the current tab, and `modal` will open a pop-up modal, `workspace` will open in the Workspace view and `clipboard` will copy the URL to system clipboard (but not launch app). Defaults to `newtab` **`hotkey`** | `number` | _Optional_ | Give frequently opened applications a numeric hotkey, between `0 - 9`. You can then just press that key to launch that application. diff --git a/src/components/LinkItems/Item.vue b/src/components/LinkItems/Item.vue index 8c2d8495a5..5ac9ace1aa 100644 --- a/src/components/LinkItems/Item.vue +++ b/src/components/LinkItems/Item.vue @@ -218,17 +218,11 @@ export default { this.intervalId = setInterval(this.checkWebsiteStatus, this.statusCheckInterval * 1000); } } - // If an alternative local URL is set, probe its reachability in the background - if (this.hasLocalUrl) { - this.startLocalUrlChecks(); - } }, beforeUnmount() { // Stop periodic ping-check and status-check when item is destroyed (e.g. navigating in multi-page setup) if (this.pingIntervalId) clearInterval(this.pingIntervalId); if (this.intervalId) clearInterval(this.intervalId); - // Stop local-URL probing timers and listeners - this.stopLocalUrlChecks(); }, }; diff --git a/src/components/LinkItems/SubItem.vue b/src/components/LinkItems/SubItem.vue index accb1579b3..3235ec2385 100644 --- a/src/components/LinkItems/SubItem.vue +++ b/src/components/LinkItems/SubItem.vue @@ -56,15 +56,6 @@ export default { return {}; }, methods: {}, - mounted() { - // If an alternative local URL is set, probe its reachability in the background - if (this.hasLocalUrl) { - this.startLocalUrlChecks(); - } - }, - beforeUnmount() { - this.stopLocalUrlChecks(); - }, }; diff --git a/src/mixins/ItemMixin.js b/src/mixins/ItemMixin.js index 91188bf181..e227feefa3 100644 --- a/src/mixins/ItemMixin.js +++ b/src/mixins/ItemMixin.js @@ -25,9 +25,9 @@ export default { contextMenuOpen: false, intervalId: undefined, // status-check setInterval() id pingIntervalId: undefined, // ping-check setInterval() id - // Local URL reachability: undefined = not yet probed, true/false = last result - localUrlReachable: undefined, + localUrlReachable: undefined, // Locally reachable? unset if not yet probed, else true/false localUrlIntervalId: undefined, // local-url re-check setInterval() id + localUrlController: undefined, // AbortController for the in-flight probe contextPos: { posX: undefined, posY: undefined, @@ -239,15 +239,13 @@ export default { }); } }, - /* Probes the configured local URL from the browser to decide if it's reachable. - Runs in the background (never blocks a click): the result feeds `effectiveUrl`, - which is already bound to the anchor's href by the time the user clicks. - Uses a no-cors fetch so cross-origin LAN services don't trip CORS — we only - care whether the request settles (reachable) or aborts/errors (unreachable). */ + /* Probes the configured local URL from the browser to decide if it's reachable */ probeLocalUrl() { if (!this.hasLocalUrl) return; const target = this.item.localUrl.trim(); + if (this.localUrlController) this.localUrlController.abort(); const controller = new AbortController(); + this.localUrlController = controller; const timer = setTimeout(() => controller.abort(), this.localUrlProbeTimeout); fetch(target, { mode: 'no-cors', @@ -256,7 +254,10 @@ export default { }) .then(() => { this.localUrlReachable = true; }) .catch(() => { this.localUrlReachable = false; }) - .finally(() => clearTimeout(timer)); + .finally(() => { + clearTimeout(timer); + if (this.localUrlController === controller) this.localUrlController = undefined; + }); }, /* Starts local-URL probing: once now, on tab re-focus, and optionally on an interval */ startLocalUrlChecks() { @@ -272,9 +273,10 @@ export default { onVisibilityProbe() { if (document.visibilityState === 'visible') this.probeLocalUrl(); }, - /* Tears down local-URL probing timers and listeners */ + /* Tears down local-URL probing timers, listeners and any in-flight probe */ stopLocalUrlChecks() { if (this.localUrlIntervalId) clearInterval(this.localUrlIntervalId); + if (this.localUrlController) this.localUrlController.abort(); document.removeEventListener('visibilitychange', this.onVisibilityProbe); }, /* Called when an item is clicked, manages the opening of modal & resets the search field */ @@ -387,4 +389,12 @@ export default { } catch { /* ignore corrupt localStorage */ } }, }, + mounted() { + // If an alternative local URL is set, probe its reachability in the background + if (this.hasLocalUrl) this.startLocalUrlChecks(); + }, + beforeUnmount() { + // Stop local-URL probing timers, listeners and any in-flight probe + this.stopLocalUrlChecks(); + }, }; diff --git a/src/utils/config/ConfigSchema.json b/src/utils/config/ConfigSchema.json index 514cac2c75..92def4edb5 100644 --- a/src/utils/config/ConfigSchema.json +++ b/src/utils/config/ConfigSchema.json @@ -1058,19 +1058,19 @@ "localUrl": { "title": "Local URL", "type": "string", - "description": "Optional alternative URL preferred when it is reachable from your browser (e.g. a LAN address). On load Dashy probes this URL; if it responds, clicking the item opens it, otherwise it falls back to the Service URL. Useful for local-vs-remote access (Tailscale, VPN, etc)" + "description": "Optional alternative URL preferred when it is reachable from your browser (e.g. a LAN address)" }, "localUrlTimeout": { "title": "Local URL Probe Timeout", "type": "number", "default": 1500, - "description": "Milliseconds to wait for the Local URL reachability probe before giving up and using the Service URL. Clamped between 300 and 5000. The probe runs in the background, so it never delays clicks" + "description": "For local URLs, ms to wait for the Local URL reachability probe before falling back to normal URL" }, "localUrlCheckInterval": { "title": "Local URL Re-check Interval", "type": "number", "default": 0, - "description": "Seconds between background re-checks of the Local URL. 0 = only check on page load and when the tab regains focus. Clamped to 300 max" + "description": "For local URLs, seconds between background re-checks of the Local URL. 0 = only check on page load" }, "displayData": { "title": "Display Data",