Repository: dermotduffy/frigate-hass-card (ts)

Issue:
Potential resolution for clip download functionality in iOS and companion app
<!-- =================================================================
***               PLEASE DO NOT IGNORE THIS TEMPLATE
***
*** This is the smallest amount of information needed to help you!
====================================================================== -->

**Checklist:**

- Please try updating to the [latest available version](https://github.com/dermotduffy/frigate-hass-card/releases).
- Please try clearing your browser cache.

**[REQUIRED] Description of problem:**

After the more recent major revamp of the frigate-card I spent a little bit of time to see if there was any way to get the download button working in the companion app on iOS, this is an issue I had brought up a while back [#issue
](https://github.com/dermotduffy/frigate-hass-card/issues/808#issuecomment-1218102360)

After doing some testing it seemed that I was actually able to use the download functionality of the frigate default UI when using the frigate-proxy and embedded in my dashboard. Clicking that download link would actually prompt a download box within the iOS companion app and even allow saving it to my camera roll.

Looking at the code some more https://github.com/dermotduffy/frigate-hass-card/blob/3e8e688b365105e43eb40e1a81fe9fe5d62bf6d0/src/utils/download.ts#L15 it seems the issue is around the window.open(url, '_blank'); does not seem to work.

Simply removing this entire if condition and always using the html5 download attribute seems to now work perfectly well on the iOS companion app. This must have been some change in the iOS app or iOS webview itself? I build a quick test of the card without this conditional included at all and all the download functionality works beautifully!

I don't a great place to test this for Android but if this technique is still needed for the Android app, I would imagine the isCompanionApp function can be changed to only return true for the Android companion app.

**[OPTIONAL] Last working release (if known):**
On iOS companion app, never

**[OPTIONAL] Additional information:**

I am more than happy to submit a PR with this minor change if someone is able to test this functionality on the android app, otherwise I should have a working android companion app device in the coming few days.

Pull request description:
fix: Downloads for iOS
- Closes #1863

Patch:
diff --git a/src/utils/companion.ts b/src/utils/companion.ts
index a770a64b..c47cdefd 100644
--- a/src/utils/companion.ts
+++ b/src/utils/companion.ts
@@ -1,3 +1,11 @@
 export const isCompanionApp = (userAgent: string): boolean => {
   return !!userAgent.match(/Home ?Assistant/);
 };
+
+export const isAndroidCompanionApp = (userAgent: string): boolean => {
+  return !!userAgent.match(/(?=.*Home ?Assistant)(?=.*Android)/);
+};
+
+export const isIOSCompanionApp = (userAgent: string): boolean => {
+  return !!userAgent.match(/(?=.*Home ?Assistant)(?=.*iOS)/);
+};
diff --git a/src/utils/download.ts b/src/utils/download.ts
index cdde9bfd..5e9b77d2 100644
--- a/src/utils/download.ts
+++ b/src/utils/download.ts
@@ -3,7 +3,6 @@ import { localize } from '../localize/localize';
 import { ExtendedHomeAssistant, FrigateCardError } from '../types';
 import { ViewMedia } from '../view/media';
 import { errorToConsole } from './basic';
-import { isCompanionApp } from './companion';
 import { homeAssistantSignPath } from './ha';
 
 export const downloadURL = (url: string, filename = 'download'): void => {
@@ -12,23 +11,18 @@ export const downloadURL = (url: string, filename = 'download'): void => {
   const isSameOrigin = new URL(url).origin === window.location.origin;
   const dataURL = url.startsWith('data:');
 
-  if (isCompanionApp(navigator.userAgent) || (!isSameOrigin && !dataURL)) {
-    // Home Assistant companion apps cannot download files without opening a
-    // new browser window.
-    //
-    // User-agents are specified here:
-    //  - Android: https://github.com/home-assistant/android/blob/b285c9525dd4837a82db931c1b2321c0511494e6/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt#L23
-    //  - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
+  if (!isSameOrigin && !dataURL) {
     window.open(url, '_blank');
-  } else {
-    // Use the HTML5 download attribute to prevent a new window from
-    // temporarily opening.
-    const link = document.createElement('a');
-    link.setAttribute('download', filename);
-    link.href = url;
-    link.click();
-    link.remove();
+    return;
   }
+
+  // Use the HTML5 download attribute to prevent a new window from
+  // temporarily opening.
+  const link = document.createElement('a');
+  link.setAttribute('download', filename);
+  link.href = url;
+  link.click();
+  link.remove();
 };
 
 export const downloadMedia = async (


Test patch:
diff --git a/tests/utils/companion.test.ts b/tests/utils/companion.test.ts
index ea5a11f2..740b3a31 100644
--- a/tests/utils/companion.test.ts
+++ b/tests/utils/companion.test.ts
@@ -1,5 +1,9 @@
 import { describe, expect, it } from 'vitest';
-import { isCompanionApp } from '../../src/utils/companion';
+import {
+  isCompanionApp,
+  isAndroidCompanionApp,
+  isIOSCompanionApp,
+} from '../../src/utils/companion';
 
 describe('isCompanionApp', () => {
   it('should return true for userAgent starting with "Home Assistant/"', () => {
@@ -18,3 +22,47 @@ describe('isCompanionApp', () => {
     expect(isCompanionApp('')).toBe(false);
   });
 });
+
+describe('isAndroidCompanionApp', () => {
+  it('should return true for userAgent containing "Home Assistant" and "Android"', () => {
+    expect(isAndroidCompanionApp('Home Assistant/1.0 (Android 1.0; 1.0)')).toBe(true);
+  });
+
+  it('should return true for userAgent containing "HomeAssistant" and "Android"', () => {
+    expect(isAndroidCompanionApp('HomeAssistant/2.0 (Android 2.0; 2.0)')).toBe(true);
+  });
+
+  it('should return false for userAgent not starting with "Home Assistant/" or "HomeAssistant/"', () => {
+    expect(isAndroidCompanionApp('Mozilla/5.0')).toBe(false);
+  });
+
+  it('should return false for userAgent containing "Home Assistant/" or "HomeAssistant/" and iOS', () => {
+    expect(
+      isAndroidCompanionApp(
+        'Home Assistant/2025.1.1 (io.robbie.HomeAssistant; build:2025.1077; iOS 18.3.0',
+      ),
+    ).toBe(false);
+  });
+});
+
+describe('isIOSCompanionApp', () => {
+  it('should return true for userAgent containing "Home Assistant" and "iOS"', () => {
+    expect(isIOSCompanionApp('Home Assistant/1.0 (iOS 1.0; 1.0)')).toBe(true);
+  });
+
+  it('should return true for userAgent containing "HomeAssistant" and "iOS"', () => {
+    expect(isIOSCompanionApp('HomeAssistant/2.0 (iOS 2.0; 2.0)')).toBe(true);
+  });
+
+  it('should return false for userAgent not starting with "Home Assistant/" or "HomeAssistant/"', () => {
+    expect(isIOSCompanionApp('Mozilla/5.0')).toBe(false);
+  });
+
+  it('should return false for userAgent containing "Home Assistant/" or "HomeAssistant/" and Android', () => {
+    expect(
+      isIOSCompanionApp(
+        'Home Assistant/2025.1.1 (io.robbie.HomeAssistant; build:2025.1077; Android 18.3.0',
+      ),
+    ).toBe(false);
+  });
+});
diff --git a/tests/utils/download.test.ts b/tests/utils/download.test.ts
index dd4ecca9..1b4363d3 100644
--- a/tests/utils/download.test.ts
+++ b/tests/utils/download.test.ts
@@ -46,20 +46,16 @@ describe('downloadURL', () => {
     expect(link.click).toBeCalled();
   });
 
-  it('should download in apps via window.open', () => {
+  it('should download different origin via window.open', () => {
     // Set the origin to the same.
     const location: Location & { origin: string } = mock<Location>();
     location.origin = 'http://foo';
     global.window.location = location;
 
-    vi.stubGlobal('navigator', {
-      userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)',
-    });
-
     const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
 
-    downloadURL('http://foo/url.mp4');
-    expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank');
+    downloadURL('http://bar/url.mp4');
+    expect(windowSpy).toBeCalledWith('http://bar/url.mp4', '_blank');
   });
 });