Skip to content

Commit 919e9f3

Browse files
fix: support file exports in Tauri webview (#763)
* fix: support file exports in Tauri webview 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 * 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. * 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. * fix: guard against empty events array in export_csv
1 parent cd5aafe commit 919e9f3

File tree

5 files changed

+138
-47
lines changed

5 files changed

+138
-47
lines changed

src/tauri-plugins.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Type declarations for Tauri plugins used via dynamic import.
3+
* These modules are only available at runtime inside a Tauri webview.
4+
*/
5+
6+
declare module '@tauri-apps/plugin-dialog' {
7+
interface SaveDialogOptions {
8+
title?: string;
9+
defaultPath?: string;
10+
filters?: Array<{ name: string; extensions: string[] }>;
11+
}
12+
export function save(options?: SaveDialogOptions): Promise<string | null>;
13+
}
14+
15+
declare module '@tauri-apps/plugin-fs' {
16+
export function writeTextFile(path: string, contents: string): Promise<void>;
17+
}

src/util/export.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Cross-platform file download utility.
3+
*
4+
* In a regular browser, uses the standard <a download> pattern.
5+
* In a Tauri webview, uses the dialog + fs plugins to show a native
6+
* save dialog and write the file directly (webviews don't support
7+
* the <a download> pattern).
8+
*
9+
* See: https://github.com/ActivityWatch/aw-tauri/issues/199
10+
*/
11+
12+
interface FileFilter {
13+
name: string;
14+
extensions: string[];
15+
}
16+
17+
function isTauri(): boolean {
18+
return '__TAURI__' in window;
19+
}
20+
21+
/**
22+
* Download/save a file with the given content.
23+
*
24+
* @param filename - Default filename for the download
25+
* @param content - File content as a string
26+
* @param mimeType - MIME type (e.g. 'application/json', 'text/csv')
27+
*/
28+
export async function downloadFile(
29+
filename: string,
30+
content: string,
31+
mimeType: string
32+
): Promise<void> {
33+
if (isTauri()) {
34+
await downloadFileTauri(filename, content, mimeType);
35+
} else {
36+
downloadFileBrowser(filename, content, mimeType);
37+
}
38+
}
39+
40+
function getFilters(mimeType: string): FileFilter[] {
41+
if (mimeType.includes('json')) {
42+
return [{ name: 'JSON', extensions: ['json'] }];
43+
} else if (mimeType.includes('csv')) {
44+
return [{ name: 'CSV', extensions: ['csv'] }];
45+
}
46+
return [];
47+
}
48+
49+
async function downloadFileTauri(
50+
filename: string,
51+
content: string,
52+
mimeType: string
53+
): Promise<void> {
54+
try {
55+
// These modules are only available in the Tauri runtime (injected by aw-tauri).
56+
// Using new Function() to bypass static analysis by webpack and rollup,
57+
// which would otherwise fail to resolve these packages at build time.
58+
// eslint-disable-next-line no-new-func
59+
const dynamicImport = new Function('m', 'return import(m)') as (m: string) => Promise<unknown>;
60+
const { save } = (await dynamicImport('@tauri-apps/plugin-dialog')) as {
61+
save: (opts: object) => Promise<string | null>;
62+
};
63+
const { writeTextFile } = (await dynamicImport('@tauri-apps/plugin-fs')) as {
64+
writeTextFile: (path: string, contents: string) => Promise<void>;
65+
};
66+
67+
const path = await save({
68+
title: 'Save export',
69+
defaultPath: filename,
70+
filters: getFilters(mimeType),
71+
});
72+
73+
if (path) {
74+
await writeTextFile(path, content);
75+
}
76+
} catch (e) {
77+
console.warn('Tauri save failed, falling back to browser download:', e);
78+
// Fall back to browser method if Tauri plugins aren't available
79+
downloadFileBrowser(filename, content, mimeType);
80+
}
81+
}
82+
83+
function downloadFileBrowser(filename: string, content: string, mimeType: string): void {
84+
const blob = new Blob([content], { type: mimeType });
85+
const url = URL.createObjectURL(blob);
86+
const link = document.createElement('a');
87+
link.href = url;
88+
link.download = filename;
89+
link.style.display = 'none';
90+
document.body.appendChild(link);
91+
link.click();
92+
document.body.removeChild(link);
93+
URL.revokeObjectURL(url);
94+
}

src/views/Buckets.vue

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ div
4848
b-dropdown(variant="outline-secondary", size="sm", text="More")
4949
// FIXME: These also exist as almost-copies in the Bucket view, can maybe be shared/reused instead.
5050
b-dropdown-item(
51-
:href="$aw.baseURL + '/api/0/buckets/' + data.item.id + '/export'",
52-
:download="'aw-bucket-export-' + data.item.id + '.json'",
51+
@click="export_bucket_json(data.item.id)",
5352
title="Export bucket to JSON",
5453
variant="secondary")
5554
icon(name="download")
@@ -100,9 +99,8 @@ div
10099
| A valid file to import is a JSON file from either an export of a single bucket or an export from multiple buckets.
101100
| If there are buckets with the same name the import will fail.
102101
b-card(header="Export buckets")
103-
b-button(:href="$aw.baseURL + '/api/0/export'",
104-
:download="'aw-bucket-export.json'",
105-
title="Export bucket to JSON",
102+
b-button(@click="export_all_buckets_json()",
103+
title="Export all buckets to JSON",
106104
variant="outline-secondary")
107105
icon(name="download")
108106
| Export all buckets as JSON
@@ -160,6 +158,7 @@ import moment from 'moment';
160158
161159
import { useServerStore } from '~/stores/server';
162160
import { useBucketsStore } from '~/stores/buckets';
161+
import { downloadFile } from '~/util/export';
163162
164163
export default {
165164
name: 'Buckets',
@@ -255,10 +254,22 @@ export default {
255254
return this.$aw.req.post('/0/import', formData, { headers });
256255
},
257256
257+
async export_bucket_json(bucketId: string) {
258+
const response = await this.$aw.req.get(`/0/buckets/${bucketId}/export`);
259+
const data = JSON.stringify(response.data, null, 2);
260+
await downloadFile(`aw-bucket-export-${bucketId}.json`, data, 'application/json');
261+
},
262+
263+
async export_all_buckets_json() {
264+
const response = await this.$aw.req.get('/0/export');
265+
const data = JSON.stringify(response.data, null, 2);
266+
await downloadFile('aw-bucket-export.json', data, 'application/json');
267+
},
268+
258269
async export_csv(bucketId: string) {
259270
const bucket = await this.bucketsStore.getBucketWithEvents({ id: bucketId });
260271
const events = bucket.events;
261-
const datakeys = Object.keys(events[0].data);
272+
const datakeys = events.length > 0 ? Object.keys(events[0].data) : [];
262273
const columns = ['timestamp', 'duration'].concat(datakeys);
263274
const data = events.map(e => {
264275
return Object.assign(
@@ -267,16 +278,10 @@ export default {
267278
);
268279
});
269280
const csv = Papa.unparse(data, { columns, header: true });
270-
const blob = new Blob([csv], { type: 'text/csv' });
271-
const url = URL.createObjectURL(blob);
272-
const link = document.createElement('a');
273-
link.href = url;
274-
link.download = `aw-events-export-${bucketId}-${new Date()
281+
const filename = `aw-events-export-${bucketId}-${new Date()
275282
.toISOString()
276283
.substring(0, 10)}.csv`;
277-
document.body.appendChild(link);
278-
link.click();
279-
document.body.removeChild(link);
284+
await downloadFile(filename, csv, 'text/csv');
280285
},
281286
},
282287
};

src/views/Report.vue

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { useCategoryStore } from '~/stores/categories';
9595
import { useBucketsStore } from '~/stores/buckets';
9696
9797
import { getClient } from '~/util/awclient';
98+
import { downloadFile } from '~/util/export';
9899
99100
export default {
100101
name: 'Report',
@@ -161,33 +162,19 @@ export default {
161162
}
162163
},
163164
164-
export_json() {
165+
async export_json() {
165166
const data = JSON.stringify(this.events, null, 2);
166-
const blob = new Blob([data], { type: 'application/json' });
167-
const url = URL.createObjectURL(blob);
168-
const link = document.createElement('a');
169-
link.href = url;
170-
link.download = 'events.json';
171-
document.body.appendChild(link);
172-
link.click();
173-
document.body.removeChild(link);
167+
await downloadFile('events.json', data, 'application/json');
174168
},
175169
176-
export_csv() {
170+
async export_csv() {
177171
const data = this.events.map(e => {
178172
return [e.timestamp, e.duration, e.data['$category'], e.data['app'], e.data['title']];
179173
});
180174
const csv = Papa.unparse(data, {
181175
columns: ['timestamp', 'duration', 'category', 'app', 'title'],
182176
});
183-
const blob = new Blob([csv], { type: 'text/csv' });
184-
const url = URL.createObjectURL(blob);
185-
const link = document.createElement('a');
186-
link.href = url;
187-
link.download = 'events.csv';
188-
document.body.appendChild(link);
189-
link.click();
190-
document.body.removeChild(link);
177+
await downloadFile('events.csv', csv, 'text/csv');
191178
},
192179
},
193180
};

src/views/settings/CategorizationSettings.vue

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import 'vue-awesome/icons/undo';
4848
import { useCategoryStore } from '~/stores/categories';
4949
5050
import _ from 'lodash';
51+
import { downloadFile } from '~/util/export';
5152
5253
const confirmationMessage = 'Your categories have unsaved changes, are you sure you want to leave?';
5354
@@ -99,27 +100,14 @@ export default {
99100
hideEditModal: function () {
100101
this.editingId = null;
101102
},
102-
exportClasses: function () {
103+
exportClasses: async function () {
103104
console.log('Exporting categories...');
104105
105106
const export_data = {
106107
categories: this.categoryStore.classes,
107108
};
108-
// Pretty-format it for easier reading
109109
const text = JSON.stringify(export_data, null, 2);
110-
const filename = 'aw-category-export.json';
111-
112-
// Initiate downloading a file by creating a hidden button and clicking it
113-
const element = document.createElement('a');
114-
element.setAttribute(
115-
'href',
116-
'data:application/json;charset=utf-8,' + encodeURIComponent(text)
117-
);
118-
element.setAttribute('download', filename);
119-
element.style.display = 'none';
120-
document.body.appendChild(element);
121-
element.click();
122-
document.body.removeChild(element);
110+
await downloadFile('aw-category-export.json', text, 'application/json');
123111
},
124112
importCategories: async function (elem) {
125113
console.log('Importing categories...');

0 commit comments

Comments
 (0)