diff --git a/docs/configuring.md b/docs/configuring.md index bf1c536991..87366f5532 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 +**`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 ae7db11ff1..5ac9ace1aa 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()" diff --git a/src/mixins/ItemMixin.js b/src/mixins/ItemMixin.js index 125c3d8a44..e227feefa3 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 + 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, @@ -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,49 @@ export default { }); } }, + /* 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', + cache: 'no-store', + signal: controller.signal, + }) + .then(() => { this.localUrlReachable = true; }) + .catch(() => { this.localUrlReachable = false; }) + .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() { + 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, 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 */ 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 +316,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': @@ -320,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 393ad42bec..92def4edb5 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)" + }, + "localUrlTimeout": { + "title": "Local URL Probe Timeout", + "type": "number", + "default": 1500, + "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": "For local URLs, seconds between background re-checks of the Local URL. 0 = only check on page load" + }, "displayData": { "title": "Display Data", "type": "object",