From ded81bf0499da55fe8cda1134fcbfa0dbe0a65db Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 22 Feb 2026 10:23:34 +0000 Subject: [PATCH 1/4] fix: support file exports in Tauri webview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a cross-platform download utility (src/util/export.ts) that detects Tauri webview at runtime and uses native save dialog + filesystem plugins instead of the browser download pattern which doesn't work in webviews. All 5 export functions (categories JSON, bucket JSON, all buckets JSON, bucket CSV, report JSON/CSV) now use the shared utility. Browser behavior is unchanged — the utility falls back to the existing Blob + createObjectURL pattern in non-Tauri contexts. Requires companion change in aw-tauri to register tauri-plugin-fs and add fs:default permission. Fixes ActivityWatch/aw-tauri#199 --- src/tauri-plugins.d.ts | 17 ++++ src/util/export.ts | 86 +++++++++++++++++++ src/views/Buckets.vue | 31 ++++--- src/views/Report.vue | 23 ++--- src/views/settings/CategorizationSettings.vue | 18 +--- 5 files changed, 129 insertions(+), 46 deletions(-) create mode 100644 src/tauri-plugins.d.ts create mode 100644 src/util/export.ts diff --git a/src/tauri-plugins.d.ts b/src/tauri-plugins.d.ts new file mode 100644 index 00000000..510ab8aa --- /dev/null +++ b/src/tauri-plugins.d.ts @@ -0,0 +1,17 @@ +/** + * Type declarations for Tauri plugins used via dynamic import. + * These modules are only available at runtime inside a Tauri webview. + */ + +declare module '@tauri-apps/plugin-dialog' { + interface SaveDialogOptions { + title?: string; + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; + } + export function save(options?: SaveDialogOptions): Promise; +} + +declare module '@tauri-apps/plugin-fs' { + export function writeTextFile(path: string, contents: string): Promise; +} diff --git a/src/util/export.ts b/src/util/export.ts new file mode 100644 index 00000000..8986ef53 --- /dev/null +++ b/src/util/export.ts @@ -0,0 +1,86 @@ +/** + * Cross-platform file download utility. + * + * In a regular browser, uses the standard pattern. + * In a Tauri webview, uses the dialog + fs plugins to show a native + * save dialog and write the file directly (webviews don't support + * the pattern). + * + * See: https://github.com/ActivityWatch/aw-tauri/issues/199 + */ + +interface FileFilter { + name: string; + extensions: string[]; +} + +function isTauri(): boolean { + return '__TAURI__' in window; +} + +/** + * Download/save a file with the given content. + * + * @param filename - Default filename for the download + * @param content - File content as a string + * @param mimeType - MIME type (e.g. 'application/json', 'text/csv') + */ +export async function downloadFile( + filename: string, + content: string, + mimeType: string +): Promise { + if (isTauri()) { + await downloadFileTauri(filename, content, mimeType); + } else { + downloadFileBrowser(filename, content, mimeType); + } +} + +function getFilters(mimeType: string): FileFilter[] { + if (mimeType.includes('json')) { + return [{ name: 'JSON', extensions: ['json'] }]; + } else if (mimeType.includes('csv')) { + return [{ name: 'CSV', extensions: ['csv'] }]; + } + return []; +} + +async function downloadFileTauri( + filename: string, + content: string, + mimeType: string +): Promise { + try { + // Dynamic imports — these modules are only available in the Tauri runtime + const { save } = await import('@tauri-apps/plugin-dialog'); + const { writeTextFile } = await import('@tauri-apps/plugin-fs'); + + const path = await save({ + title: 'Save export', + defaultPath: filename, + filters: getFilters(mimeType), + }); + + if (path) { + await writeTextFile(path, content); + } + } catch (e) { + console.warn('Tauri save failed, falling back to browser download:', e); + // Fall back to browser method if Tauri plugins aren't available + downloadFileBrowser(filename, content, mimeType); + } +} + +function downloadFileBrowser(filename: string, content: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/src/views/Buckets.vue b/src/views/Buckets.vue index 3a87cc0d..398f9fd8 100644 --- a/src/views/Buckets.vue +++ b/src/views/Buckets.vue @@ -48,8 +48,7 @@ div b-dropdown(variant="outline-secondary", size="sm", text="More") // FIXME: These also exist as almost-copies in the Bucket view, can maybe be shared/reused instead. b-dropdown-item( - :href="$aw.baseURL + '/api/0/buckets/' + data.item.id + '/export'", - :download="'aw-bucket-export-' + data.item.id + '.json'", + @click="export_bucket_json(data.item.id)", title="Export bucket to JSON", variant="secondary") icon(name="download") @@ -100,9 +99,8 @@ div | A valid file to import is a JSON file from either an export of a single bucket or an export from multiple buckets. | If there are buckets with the same name the import will fail. b-card(header="Export buckets") - b-button(:href="$aw.baseURL + '/api/0/export'", - :download="'aw-bucket-export.json'", - title="Export bucket to JSON", + b-button(@click="export_all_buckets_json()", + title="Export all buckets to JSON", variant="outline-secondary") icon(name="download") | Export all buckets as JSON @@ -160,6 +158,7 @@ import moment from 'moment'; import { useServerStore } from '~/stores/server'; import { useBucketsStore } from '~/stores/buckets'; +import { downloadFile } from '~/util/export'; export default { name: 'Buckets', @@ -255,6 +254,18 @@ export default { return this.$aw.req.post('/0/import', formData, { headers }); }, + async export_bucket_json(bucketId: string) { + const response = await this.$aw.req.get(`/0/buckets/${bucketId}/export`); + const data = JSON.stringify(response.data, null, 2); + await downloadFile(`aw-bucket-export-${bucketId}.json`, data, 'application/json'); + }, + + async export_all_buckets_json() { + const response = await this.$aw.req.get('/0/export'); + const data = JSON.stringify(response.data, null, 2); + await downloadFile('aw-bucket-export.json', data, 'application/json'); + }, + async export_csv(bucketId: string) { const bucket = await this.bucketsStore.getBucketWithEvents({ id: bucketId }); const events = bucket.events; @@ -267,16 +278,10 @@ export default { ); }); const csv = Papa.unparse(data, { columns, header: true }); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `aw-events-export-${bucketId}-${new Date() + const filename = `aw-events-export-${bucketId}-${new Date() .toISOString() .substring(0, 10)}.csv`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + await downloadFile(filename, csv, 'text/csv'); }, }, }; diff --git a/src/views/Report.vue b/src/views/Report.vue index 440339f6..e92a1bf7 100644 --- a/src/views/Report.vue +++ b/src/views/Report.vue @@ -95,6 +95,7 @@ import { useCategoryStore } from '~/stores/categories'; import { useBucketsStore } from '~/stores/buckets'; import { getClient } from '~/util/awclient'; +import { downloadFile } from '~/util/export'; export default { name: 'Report', @@ -161,33 +162,19 @@ export default { } }, - export_json() { + async export_json() { const data = JSON.stringify(this.events, null, 2); - const blob = new Blob([data], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = 'events.json'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + await downloadFile('events.json', data, 'application/json'); }, - export_csv() { + async export_csv() { const data = this.events.map(e => { return [e.timestamp, e.duration, e.data['$category'], e.data['app'], e.data['title']]; }); const csv = Papa.unparse(data, { columns: ['timestamp', 'duration', 'category', 'app', 'title'], }); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = 'events.csv'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + await downloadFile('events.csv', csv, 'text/csv'); }, }, }; diff --git a/src/views/settings/CategorizationSettings.vue b/src/views/settings/CategorizationSettings.vue index 3d3b436d..0b40f16e 100644 --- a/src/views/settings/CategorizationSettings.vue +++ b/src/views/settings/CategorizationSettings.vue @@ -48,6 +48,7 @@ import 'vue-awesome/icons/undo'; import { useCategoryStore } from '~/stores/categories'; import _ from 'lodash'; +import { downloadFile } from '~/util/export'; const confirmationMessage = 'Your categories have unsaved changes, are you sure you want to leave?'; @@ -99,27 +100,14 @@ export default { hideEditModal: function () { this.editingId = null; }, - exportClasses: function () { + exportClasses: async function () { console.log('Exporting categories...'); const export_data = { categories: this.categoryStore.classes, }; - // Pretty-format it for easier reading const text = JSON.stringify(export_data, null, 2); - const filename = 'aw-category-export.json'; - - // Initiate downloading a file by creating a hidden button and clicking it - const element = document.createElement('a'); - element.setAttribute( - 'href', - 'data:application/json;charset=utf-8,' + encodeURIComponent(text) - ); - element.setAttribute('download', filename); - element.style.display = 'none'; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); + await downloadFile('aw-category-export.json', text, 'application/json'); }, importCategories: async function (elem) { console.log('Importing categories...'); From bf8ef99f2520c9c1daa55bdbd15be49617a3fab3 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 22 Feb 2026 10:31:29 +0000 Subject: [PATCH 2/4] fix: use bundler-ignore comments for optional Tauri plugin imports The @tauri-apps/plugin-dialog and @tauri-apps/plugin-fs modules are not in package.json since they're only available in the aw-tauri runtime. The dynamic imports with try/catch already handle graceful fallback, but bundlers (webpack, vite) still try to resolve modules at build time. Add webpackIgnore and @vite-ignore magic comments to skip static resolution while keeping the dynamic import pattern intact. --- src/util/export.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/util/export.ts b/src/util/export.ts index 8986ef53..1b52291d 100644 --- a/src/util/export.ts +++ b/src/util/export.ts @@ -52,9 +52,15 @@ async function downloadFileTauri( mimeType: string ): Promise { try { - // Dynamic imports — these modules are only available in the Tauri runtime - const { save } = await import('@tauri-apps/plugin-dialog'); - const { writeTextFile } = await import('@tauri-apps/plugin-fs'); + // Dynamic imports — these modules are only available in the Tauri runtime. + // webpackIgnore and vite-ignore prevent bundlers from trying to resolve these + // at build time (they're not in package.json; they're injected by aw-tauri). + const { save } = await import( + /* webpackIgnore: true */ /* @vite-ignore */ '@tauri-apps/plugin-dialog' + ); + const { writeTextFile } = await import( + /* webpackIgnore: true */ /* @vite-ignore */ '@tauri-apps/plugin-fs' + ); const path = await save({ title: 'Save export', From b6142617ae56685a73f51c562e790deddb9594e8 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 22 Feb 2026 10:47:52 +0000 Subject: [PATCH 3/4] fix: use new Function() to bypass bundler static analysis for Tauri imports webpack4 doesn't support /* webpackIgnore */ magic comments, and rollup ignores /* @vite-ignore */ during production builds. Using new Function() to create a runtime-only dynamic import bypasses static analysis in both bundlers while still working correctly in the Tauri webview at runtime. --- src/util/export.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/util/export.ts b/src/util/export.ts index 1b52291d..a7cffa21 100644 --- a/src/util/export.ts +++ b/src/util/export.ts @@ -52,15 +52,17 @@ async function downloadFileTauri( mimeType: string ): Promise { try { - // Dynamic imports — these modules are only available in the Tauri runtime. - // webpackIgnore and vite-ignore prevent bundlers from trying to resolve these - // at build time (they're not in package.json; they're injected by aw-tauri). - const { save } = await import( - /* webpackIgnore: true */ /* @vite-ignore */ '@tauri-apps/plugin-dialog' - ); - const { writeTextFile } = await import( - /* webpackIgnore: true */ /* @vite-ignore */ '@tauri-apps/plugin-fs' - ); + // These modules are only available in the Tauri runtime (injected by aw-tauri). + // Using new Function() to bypass static analysis by webpack and rollup, + // which would otherwise fail to resolve these packages at build time. + // eslint-disable-next-line no-new-func + const dynamicImport = new Function('m', 'return import(m)') as (m: string) => Promise; + const { save } = (await dynamicImport('@tauri-apps/plugin-dialog')) as { + save: (opts: object) => Promise; + }; + const { writeTextFile } = (await dynamicImport('@tauri-apps/plugin-fs')) as { + writeTextFile: (path: string, contents: string) => Promise; + }; const path = await save({ title: 'Save export', From ef122567af3a215aec8d76227029f6e60ad810c7 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 22 Feb 2026 10:53:16 +0000 Subject: [PATCH 4/4] fix: guard against empty events array in export_csv --- src/views/Buckets.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Buckets.vue b/src/views/Buckets.vue index 398f9fd8..1c9100ef 100644 --- a/src/views/Buckets.vue +++ b/src/views/Buckets.vue @@ -269,7 +269,7 @@ export default { async export_csv(bucketId: string) { const bucket = await this.bucketsStore.getBucketWithEvents({ id: bucketId }); const events = bucket.events; - const datakeys = Object.keys(events[0].data); + const datakeys = events.length > 0 ? Object.keys(events[0].data) : []; const columns = ['timestamp', 'duration'].concat(datakeys); const data = events.map(e => { return Object.assign(