diff --git a/AGENTS.md b/AGENTS.md
index aaff844dc..ea5e80d5d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -270,3 +270,20 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
- Implement proper caching strategies and connection pooling
- Avoid blocking operations in main application loop
- Include appropriate comments for complex business logic
+
+## Pull Request Descriptions
+
+Structure PR descriptions in this order. No headlines. Be concise.
+
+1. **References first line**: link related issues or PRs (`fixes #1123`, `replaces #222`, `pairs with org/repo#345`). PRs should almost always reference an issue or related PR — only skip in rare exceptions (e.g. trivial typo fixes).
+2. **Intro**: one or a few concise sentences framing what the PR does and why it was created this way. The full problem description belongs in the linked issue, not here.
+3. **Bullet list**: most significant changes or user-facing implications. Lead with the most significant.
+4. **TODO section** (only if open points remain):
+
+ ```
+ **TODO**
+ - [ ] item a
+ - [ ] item b
+ ```
+
+Avoid file paths, line numbers, or code listings reproduced from the diff. Include a code snippet only when it conveys the contract (event shape, API signature) more clearly than prose. No testing checklists, no co-author footers, no generator footers.
diff --git a/assets/js/components/Config/BackupRestoreModal.vue b/assets/js/components/Config/BackupRestoreModal.vue
index 88c2105e4..67d673c02 100644
--- a/assets/js/components/Config/BackupRestoreModal.vue
+++ b/assets/js/components/Config/BackupRestoreModal.vue
@@ -170,6 +170,7 @@
import { defineComponent } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import api, { downloadFile } from "@/api";
+import { dispatchDownload } from "@/utils/native";
import PropertyFileField from "./PropertyFileField.vue";
import FormRow from "./FormRow.vue";
import { isLoggedIn } from "../Auth/auth";
@@ -296,6 +297,10 @@ export default defineComponent({
return r;
},
async downloadBackup() {
+ if (dispatchDownload("/api/system/backup", "POST", { password: this.password })) {
+ this.closeConfirmModal();
+ return;
+ }
const res = await this.call(
api.post(
"/system/backup",
diff --git a/assets/js/components/Config/HemsModal.vue b/assets/js/components/Config/HemsModal.vue
index 8f1b99fd4..d215cafc5 100644
--- a/assets/js/components/Config/HemsModal.vue
+++ b/assets/js/components/Config/HemsModal.vue
@@ -25,7 +25,12 @@
$t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) })
}}
-
+
{{ $t("config.hems.downloadCsv") }}
@@ -40,6 +45,7 @@
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/hems.yaml?raw";
import api from "../../api";
+import { handleDownloadClick } from "../../utils/native";
import formatter from "../../mixins/formatter";
export default {
@@ -78,6 +84,7 @@ export default {
},
},
methods: {
+ handleDownloadClick,
async loadSessions() {
try {
const response = await api.get("gridsessions", {
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index f2b54fbd7..c5cbdc156 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -19,6 +19,7 @@ declare global {
}
interface Window {
ReactNativeWebView?: WebView;
+ evccAppCapabilities?: string[];
}
}
diff --git a/assets/js/utils/native.ts b/assets/js/utils/native.ts
index 852f9d6bb..a93b334fd 100644
--- a/assets/js/utils/native.ts
+++ b/assets/js/utils/native.ts
@@ -9,6 +9,27 @@ export function appDetection() {
}
}
-export function sendToApp(data: { type: string }) {
+export function hasAppCapability(capability: string): boolean {
+ return isApp() && window.evccAppCapabilities?.includes(capability) === true;
+}
+
+type AppMessage =
+ | { type: "online" | "offline" | "settings" }
+ | { type: "download"; url: string; method?: string; body?: unknown };
+
+export function sendToApp(data: AppMessage) {
window.ReactNativeWebView?.postMessage(JSON.stringify(data));
}
+
+export function handleDownloadClick(event: Event, url: string) {
+ if (dispatchDownload(url)) {
+ event.preventDefault();
+ }
+}
+
+export function dispatchDownload(url: string, method?: string, body?: unknown): boolean {
+ if (!hasAppCapability("download")) return false;
+ const absolute = new URL(url, window.location.href).toString();
+ sendToApp({ type: "download", url: absolute, method, body });
+ return true;
+}
diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue
index b8fa80235..edac95fc2 100644
--- a/assets/js/views/History.vue
+++ b/assets/js/views/History.vue
@@ -100,6 +100,7 @@
download
class="text-muted small history-csv-link"
data-testid="history-csv-download"
+ @click="handleDownloadClick($event, csvLink)"
>
{{ $t("main.history.downloadCsv") }}
@@ -126,6 +127,7 @@ import { PERIODS } from "../components/Sessions/types";
import { GROUP_ORDER, groupColor } from "../components/History/groups";
import colors, { resolveColors, deviceColorMap } from "../colors";
import LegendList from "../components/Sessions/LegendList.vue";
+import { handleDownloadClick } from "@/utils/native";
import formatter, { POWER_UNIT } from "../mixins/formatter";
import api from "../api";
import store from "../store";
@@ -368,6 +370,7 @@ export default defineComponent({
},
methods: {
groupColor,
+ handleDownloadClick,
legendsForGroup(group: string): Legend[] {
const items = this.entityLegends(group);
const focused = this.focusedEntity[group] ?? null;
diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue
index 436e8e0a7..bc99a324a 100644
--- a/assets/js/views/Log.vue
+++ b/assets/js/views/Log.vue
@@ -28,6 +28,7 @@
:aria-label="$t('log.download')"
:href="downloadUrl"
download
+ @click="handleDownloadClick($event, downloadUrl)"
>
l.toUpperCase()).join("|")})`);
@@ -222,6 +224,7 @@ export default defineComponent({
this.stopInterval();
},
methods: {
+ handleDownloadClick,
async updateLogs(showAll: boolean = false) {
// prevent concurrent requests
if (this.busy) return;
diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue
index 752986107..a8fe22f48 100644
--- a/assets/js/views/Sessions.vue
+++ b/assets/js/views/Sessions.vue
@@ -159,6 +159,7 @@
:href="csvLink"
download
data-testid="sessions-download"
+ @click="handleDownloadClick($event, csvLink)"
>
{{ csvLinkLabel }}
@@ -211,6 +212,7 @@ import settings from "../settings";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue";
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
+import { handleDownloadClick } from "@/utils/native";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import TotalIcon from "../components/MaterialIcon/Total.vue";
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
@@ -671,6 +673,7 @@ export default defineComponent({
this.loadSessions();
},
methods: {
+ handleDownloadClick,
changePeriod(newPeriod: PERIODS) {
let month: number | undefined = this.month;
let year: number | undefined = this.year;
diff --git a/tests/backup-restore.spec.ts b/tests/backup-restore.spec.ts
index 3c9660f0c..8b9c02333 100644
--- a/tests/backup-restore.spec.ts
+++ b/tests/backup-restore.spec.ts
@@ -1,6 +1,12 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl, restart } from "./evcc";
-import { openMoreMenu, expectModalVisible, expectModalHidden } from "./utils";
+import {
+ openMoreMenu,
+ expectModalVisible,
+ expectModalHidden,
+ enableAppContext,
+ expectAppEvent,
+} from "./utils";
import fs from "fs";
import path from "path";
@@ -247,3 +253,28 @@ test.describe("backup and restore", async () => {
await stop();
});
});
+
+test.describe("backup in app context", async () => {
+ test("download backup dispatches POST event with password body", async ({ page }) => {
+ await enableAppContext(page);
+ await start();
+ await page.goto("/#/config");
+
+ await page.getByRole("button", { name: "Backup & Restore" }).click();
+ const backupModal = page.getByTestId("backup-restore-modal");
+ await expectModalVisible(backupModal);
+
+ await backupModal.getByRole("button", { name: "Download backup..." }).click();
+ const backupConfirmModal = page.getByTestId("backup-restore-confirm-modal");
+ await expectModalVisible(backupConfirmModal);
+
+ await backupConfirmModal.getByRole("button", { name: "Download backup" }).click();
+ expect(await expectAppEvent(page)).toMatchObject({
+ type: "download",
+ url: expect.stringContaining("/api/system/backup"),
+ method: "POST",
+ body: { password: "" },
+ });
+ await stop();
+ });
+});
diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts
index 0f7324cf2..6a406b75f 100644
--- a/tests/hems.spec.ts
+++ b/tests/hems.spec.ts
@@ -1,6 +1,13 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
-import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils";
+import {
+ expectModalVisible,
+ expectModalHidden,
+ editorClear,
+ editorPaste,
+ enableAppContext,
+ expectAppEvent,
+} from "./utils";
import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator";
test.use({ baseURL: baseUrl() });
@@ -109,6 +116,25 @@ limit:
await stopSimulator();
});
+ test.describe("grid sessions CSV in app context", () => {
+ test("dispatches download event", async ({ page }) => {
+ await enableAppContext(page);
+ await start(CONFIG, "hems.sql");
+ await page.goto("/#/config");
+
+ await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
+ const hemsModal = page.getByTestId("hems-modal");
+ await expectModalVisible(hemsModal);
+
+ const csvLink = hemsModal.getByRole("link", { name: "Download CSV" });
+ await csvLink.click();
+ expect(await expectAppEvent(page)).toMatchObject({
+ type: "download",
+ url: expect.stringContaining("/api/gridsessions?format=csv&lang=en"),
+ });
+ });
+ });
+
test("external control with circuits", async ({ page }) => {
const GRID_CONFIG = "hems-grid.evcc.yaml";
await start(GRID_CONFIG);
diff --git a/tests/logs.spec.ts b/tests/logs.spec.ts
index 111571727..f9337918b 100644
--- a/tests/logs.spec.ts
+++ b/tests/logs.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
-import { openMoreMenu } from "./utils";
+import { openMoreMenu, enableAppContext, expectAppEvent } from "./utils";
test.use({ baseURL: baseUrl() });
@@ -49,3 +49,15 @@ test.describe("features", async () => {
await expect(page.getByTestId("log-content")).toContainText("UI local");
});
});
+
+test.describe("log download in app context", async () => {
+ test("dispatches download event", async ({ page }) => {
+ await enableAppContext(page);
+ await page.goto("/#/log");
+ await page.getByRole("link", { name: "Download complete log" }).click();
+ expect(await expectAppEvent(page)).toMatchObject({
+ type: "download",
+ url: expect.stringContaining("/api/system/log?level=debug&format=txt"),
+ });
+ });
+});
diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts
index 4e4efe7dd..ee2010db0 100644
--- a/tests/sessions.spec.ts
+++ b/tests/sessions.spec.ts
@@ -1,6 +1,6 @@
import { test, expect, devices, type Page } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
-import { expectModalVisible, expectModalHidden } from "./utils";
+import { expectModalVisible, expectModalHidden, enableAppContext, expectAppEvent } from "./utils";
test.use({ baseURL: baseUrl() });
@@ -309,6 +309,26 @@ test.describe("csv export", async () => {
});
});
+test.describe("csv export download", async () => {
+ test("in browser context", async ({ page }) => {
+ await page.goto("/#/sessions?period=total");
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByRole("link", { name: "Download total CSV" }).click();
+ const download = await downloadPromise;
+ expect(download.url()).toContain("/api/sessions?format=csv");
+ });
+
+ test("in app context", async ({ page }) => {
+ await enableAppContext(page);
+ await page.goto("/#/sessions?period=total");
+ await page.getByRole("link", { name: "Download total CSV" }).click();
+ expect(await expectAppEvent(page)).toMatchObject({
+ type: "download",
+ url: expect.stringContaining("/api/sessions?format=csv&lang=en"),
+ });
+ });
+});
+
test.describe("session details", async () => {
test("show session details (session 5)", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
diff --git a/tests/utils.ts b/tests/utils.ts
index 8977b1e3d..0d9403daa 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -130,3 +130,33 @@ export async function getDatalistOptions(input: Locator): Promise {
return Array.from(datalist?.querySelectorAll("option") || []).map((opt) => opt.value);
});
}
+
+type AppState = {
+ evccAppCapabilities: string[];
+ __appEvent: unknown;
+ ReactNativeWebView: { postMessage: (m: string) => void };
+};
+
+export async function enableAppContext(page: Page): Promise {
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "evcc/playwright",
+ configurable: true,
+ });
+ const w = window as unknown as AppState;
+ w.evccAppCapabilities = ["download"];
+ w.__appEvent = undefined;
+ w.ReactNativeWebView = {
+ postMessage: (msg: string) => {
+ w.__appEvent = JSON.parse(msg);
+ },
+ };
+ });
+}
+
+export async function expectAppEvent(page: Page): Promise {
+ await expect
+ .poll(async () => page.evaluate(() => (window as unknown as AppState).__appEvent !== undefined))
+ .toBe(true);
+ return page.evaluate(() => (window as unknown as AppState).__appEvent);
+}