fix(filebrowser_quantum): keep the no-preview Download and Open file links inside the ingress panel (#3033)

* fix(filebrowser_quantum): keep the no-preview Download and Open file links inside the ingress panel

1.5.3.2 fixed the download anchor api/resources.js builds and clicks itself,
but the 'no preview available' screen -- what a .zip or .bin gets -- offers its
own Download and 'Open file' buttons as target="_blank" links, and so does the
share list in settings. The companion app has no navigationAction policy
delegate, so every new-window request reaches createWebViewWith and is handed
to an external browser, which carries no ingress session cookie: 401.

Replaces the wrapper around HTMLAnchorElement.prototype.click with a single
capturing click listener. It reaches the hidden anchor exactly as before -- a
programmatic .click() dispatches through the document like a real one -- and
also the two the user clicks, which the wrapper never saw.

Downloads gain the attribute everywhere; dropping target="_blank" is limited
to the companion app, identified by the Mobile/HomeAssistant marker it appends
to the user agent, because in a real browser a new tab is the better
behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(filebrowser_quantum): leave modified clicks alone, and correct the comments

Review of #3033 found that the listener ran for every click, so a
cmd/ctrl/shift-click on the visible Download link -- an explicit request for a
separate context -- was turned into a download instead. It now only touches
unmodified primary clicks, which is also what a programmatic .click() reports
(button 0, no modifiers), so the hidden anchor is unaffected.

Also corrects three overclaims: the listener reaches connected anchors only
(both shipped download paths append theirs first); absolute sidebar links go
through window.open rather than an anchor and are not covered; and links inside
the pdf, srcdoc-preview and OnlyOffice iframes are a separate document this
listener never sees. Records that an error response is now saved as a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(filebrowser_quantum): say why the download branch also drops target

Review of #3033 read the unconditional target removal as contradicting the
comment above it. The removal is deliberate: with the download attribute set, a
same-origin link downloads and never opens a tab, so it changes nothing in a
browser (measured), but it stops the companion app from taking its new-window
path before it considers the download -- an ordering not testable from outside
iOS. The comment now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alexandre
2026-08-31 10:54:41 +02:00
committed by GitHub
parent 020d0ca897
commit 59690b157d
3 changed files with 80 additions and 25 deletions

View File

@@ -1,4 +1,18 @@
## 1.5.3.3 (2026-08-31)
- Complete the iOS companion app fix from 1.5.3.2. The "no preview available"
screen -- what you get for a `.zip`, `.bin` or anything else FileBrowser
cannot render -- offers its Download and "Open file" buttons as new-tab
links, and so does the share list in settings. The app hands every new tab
to an external browser, which carries no ingress session, so those answered
401. Those two buttons and the settings share links now stay in the panel
when running in the companion app, and open a new tab as before in a normal
browser -- as does a cmd/ctrl/shift-click anywhere, which is left alone.
Download also saves the file instead of displaying it, which 1.5.3.2 only
fixed for the download button in the file list. Links the app opens without
a link element, such as the public-share sidebar download, are still
affected.
## 1.5.3.2 (2026-08-30)
- Fix Download in the Home Assistant iOS companion app (iOS 17 and later),
where a file opened and showed its content with no way to save it.

View File

@@ -118,4 +118,4 @@ schema:
slug: filebrowser_quantum
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "1.5.3.2"
version: "1.5.3.3"

View File

@@ -41,31 +41,72 @@ server {
# same instance's files/ or public/share/ route. Same shape as the komga
# add-on's ingress filter.
#
# 2. Download. FileBrowser downloads a file by building an <a> with no
# download attribute, clicking it, and letting the attachment response do
# the rest. The Home Assistant iOS companion app is a WKWebView, and a
# download happens there only when WebKit turns a navigation *action*
# into a WKDownload, which is what the download attribute does -- the app
# hands that to its own download manager
# (WebViewController+WebKitDelegates.swift, navigationAction:didBecome
# download:). Its response policy delegate returns .allow for every
# sub-frame and never returns .download, so a plain attachment navigation
# inside the ingress panel is just rendered: a text file opens and shows
# its content with no way to save it. Adding the attribute makes the same
# click a real download. Desktop browsers already downloaded these and
# are unaffected, and an empty value keeps the filename the server sends
# in Content-Disposition. Matched on the two exact download endpoints,
# minus inline=true: the "no preview available" fallback renders an
# "Open file" link on the same endpoint with that parameter
# (views/files/Preview.vue), and it is meant to open, not save. Only
# programmatic clicks pass through here -- a person clicking a link never
# calls HTMLAnchorElement.prototype.click -- so this reaches
# FileBrowser's own hidden download anchor and nothing a user clicks.
# 2. Saving and opening a file. FileBrowser downloads by clicking an <a>
# that carries no download attribute -- a hidden one it builds itself
# (api/resources.js), and two visible ones in the "no preview available"
# fallback (views/files/Preview.vue), which are also target="_blank".
# Neither works in the Home Assistant companion app:
#
# Not covered: the public-share sidebar downloads with window.open()
# rather than an anchor, and the app's download manager is gated on
# iOS 17 (WebViewController+WebKitDelegates.swift).
sub_filter "window.__pwaDeferredPrompt = null;" "window.__pwaDeferredPrompt = null;(function(){var o=window.open;window.open=function(u,n,f){try{var b=(window.globalVars||{}).baseURL;if(u&&n==='_blank'&&!f&&b){if(b.slice(-1)!=='/')b+='/';var t=new URL(u,location.href);if((t.protocol==='http:'||t.protocol==='https:')&&t.origin===location.origin&&(t.pathname.indexOf(b+'files/')===0||t.pathname.indexOf(b+'public/share/')===0)){location.assign(t.href);return window}}}catch(e){}return o.apply(window,arguments)};var c=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){try{var b=(window.globalVars||{}).baseURL;if(b&&this.href&&!this.hasAttribute('download')){if(b.slice(-1)!=='/')b+='/';var t=new URL(this.href,location.href);if(t.origin===location.origin&&t.searchParams.get('inline')!=='true'&&(t.pathname===b+'api/resources/download'||t.pathname===b+'public/api/resources/download')){this.download=''}}}catch(e){}return c.apply(this,arguments)}})();";
# - A download happens in its WKWebView only when WebKit turns a
# navigation *action* into a WKDownload, which is what the download
# attribute does; the app hands that to its own download manager
# (WebViewController+WebKitDelegates.swift, navigationAction:didBecome
# download:, iOS 17+). Its response policy delegate returns .allow for
# every sub-frame and never returns .download, so a plain attachment
# navigation in the ingress panel is just rendered: the file opens and
# shows its content with no way to save it.
# - target="_blank" is worse. The app has no navigationAction policy
# delegate, so every new-window request reaches createWebViewWith,
# which hands the url to an external browser. That browser carries no
# ingress session cookie, so Home Assistant answers 401.
#
# One capturing click listener covers both, and covers the hidden anchor
# too: a programmatic .click() on an anchor that is in the document
# dispatches through it exactly like a real one -- same event, button 0,
# no modifiers -- and the capture phase runs before the link is followed.
# It replaces an earlier wrapper around HTMLAnchorElement.prototype.click,
# which reached the hidden anchor but not the two the user clicks. The
# wrapper would also have caught a click on a *detached* anchor, which
# this cannot; both of FileBrowser's download paths append theirs to the
# body first (api/resources.js), so nothing is lost today.
#
# Only unmodified primary clicks are touched. A cmd/ctrl/shift-click is
# an explicit request for a separate context and is left alone, which
# matters on desktop and in the Mac Catalyst app.
#
# Downloads gain the attribute everywhere -- desktop browsers already
# saved these on a plain click, and an empty value keeps the filename the
# server sends in Content-Disposition. One behaviour does change there:
# if the endpoint answers with an error the body is saved as a file
# instead of being shown, because the decision is made before the
# response exists. Matched on the two exact download endpoints minus
# inline=true, which is the fallback's "Open file" link and is meant to
# open rather than save. That branch drops target="_blank" in every
# browser too: with the attribute present a same-origin link downloads
# and never opens a tab (measured), so it changes nothing here, but it
# stops the companion app from taking its new-window path before it
# considers the download -- an ordering that cannot be tested from
# outside iOS.
#
# Dropping target="_blank" from links that are *not* downloads is limited
# to the companion app, identified by the Mobile/HomeAssistant marker it
# appends to the user agent
# (HAAPI.swift, applicationNameForUserAgent -- it covers iPhone, iPad and
# Mac Catalyst). In a real browser a new tab is the better behaviour and
# is left alone; in the app it is a 401. It applies to any same-origin
# link below the add-on's own base path, which in practice is the
# "Open file" button and the share links in settings
# (views/settings/Shares.vue). Links to other origins keep their new tab,
# and only an exact target="_blank" is matched, so named windows, _parent
# and _top are untouched.
#
# Not covered, and still 401 in the app: anything the app opens with
# window.open() rather than an anchor, which is the public-share sidebar
# download and absolute sidebar links (components/sidebar/Links.vue); and
# links inside a document FileBrowser renders in its own iframe -- the
# pdf viewer, the srcdoc markdown/html preview, OnlyOffice -- because a
# listener on this document never sees another document's clicks.
sub_filter "window.__pwaDeferredPrompt = null;" "window.__pwaDeferredPrompt = null;(function(){var o=window.open;window.open=function(u,n,f){try{var b=(window.globalVars||{}).baseURL;if(u&&n==='_blank'&&!f&&b){if(b.slice(-1)!=='/')b+='/';var t=new URL(u,location.href);if((t.protocol==='http:'||t.protocol==='https:')&&t.origin===location.origin&&(t.pathname.indexOf(b+'files/')===0||t.pathname.indexOf(b+'public/share/')===0)){location.assign(t.href);return window}}}catch(e){}return o.apply(window,arguments)};document.addEventListener('click',function(e){try{if(e.button||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;var a=e.target&&e.target.closest?e.target.closest('a'):null;if(!a||!a.href)return;var b=(window.globalVars||{}).baseURL;if(!b)return;if(b.slice(-1)!=='/')b+='/';var t=new URL(a.href,location.href);if(t.origin!==location.origin)return;if(!a.hasAttribute('download')&&t.searchParams.get('inline')!=='true'&&(t.pathname===b+'api/resources/download'||t.pathname===b+'public/api/resources/download')){a.download='';a.removeAttribute('target');return}if(a.target==='_blank'&&t.pathname.indexOf(b)===0&&navigator.userAgent.indexOf('Mobile/HomeAssistant')!==-1){a.removeAttribute('target')}}catch(err){}},true)})();";
}
}