mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
15 Commits
feat/auto-
...
bugfix/plu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
922f9fd911 | ||
|
|
64165d4c4b | ||
|
|
710aa8ad08 | ||
|
|
dad0ee062b | ||
|
|
b1a2501e83 | ||
|
|
ef0ce7fc9d | ||
|
|
1eb5c50732 | ||
|
|
62097f287a | ||
|
|
31140fc9b8 | ||
|
|
bc4e2238bb | ||
|
|
f8aae08784 | ||
|
|
d5042132b7 | ||
|
|
59b7f374d2 | ||
|
|
912bd10367 | ||
|
|
18b5429574 |
@@ -158,43 +158,28 @@ export default defineConfig(async ({ mode }) => {
|
||||
? undefined
|
||||
: VitePWA({
|
||||
registerType: 'prompt',
|
||||
// TODO: The plugin worker build is currently disabled due to issues with offline support. Fix this
|
||||
// strategies: 'injectManifest',
|
||||
// srcDir: 'service-worker',
|
||||
// filename: 'plugin-sw.js',
|
||||
// manifest: {
|
||||
// name: 'Actual',
|
||||
// short_name: 'Actual',
|
||||
// description: 'A local-first personal finance tool',
|
||||
// theme_color: '#5c3dbb',
|
||||
// background_color: '#5c3dbb',
|
||||
// display: 'standalone',
|
||||
// start_url: './',
|
||||
// },
|
||||
// injectManifest: {
|
||||
// maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
|
||||
// swSrc: `service-worker/plugin-sw.js`,
|
||||
// },
|
||||
devOptions: {
|
||||
enabled: true, // We need service worker in dev mode to work with plugins
|
||||
type: 'module',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'service-worker',
|
||||
filename: 'plugin-sw.js',
|
||||
manifest: {
|
||||
name: 'Actual',
|
||||
short_name: 'Actual',
|
||||
description: 'A local-first personal finance tool',
|
||||
theme_color: '#5c3dbb',
|
||||
background_color: '#5c3dbb',
|
||||
display: 'standalone',
|
||||
start_url: './',
|
||||
},
|
||||
workbox: {
|
||||
injectManifest: {
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
|
||||
swSrc: `service-worker/plugin-sw.js`,
|
||||
globPatterns: [
|
||||
'**/*.{js,css,html,txt,wasm,sql,sqlite,ico,png,woff2,webmanifest}',
|
||||
],
|
||||
ignoreURLParametersMatching: [/^v$/],
|
||||
navigateFallback: '/index.html',
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
|
||||
navigateFallbackDenylist: [
|
||||
/^\/account\/.*$/,
|
||||
/^\/admin\/.*$/,
|
||||
/^\/secret\/.*$/,
|
||||
/^\/openid\/.*$/,
|
||||
/^\/plugins\/.*$/,
|
||||
/^\/kcab\/.*$/,
|
||||
/^\/plugin-data\/.*$/,
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true, // We need service worker in dev mode to work with plugins
|
||||
type: 'module',
|
||||
},
|
||||
}),
|
||||
injectShims(),
|
||||
|
||||
@@ -228,27 +228,61 @@ async function _removeFile(filepath: string) {
|
||||
|
||||
// Load files from the server that should exist by default
|
||||
async function populateDefaultFilesystem() {
|
||||
const index = await (
|
||||
await fetch(process.env.PUBLIC_URL + 'data-file-index.txt')
|
||||
).text();
|
||||
const files = index
|
||||
.split('\n')
|
||||
.map(name => name.trim())
|
||||
.filter(name => name !== '');
|
||||
const fetchFile = url => fetch(url).then(res => res.arrayBuffer());
|
||||
try {
|
||||
const indexResponse = await fetch(
|
||||
process.env.PUBLIC_URL + 'data-file-index.txt',
|
||||
);
|
||||
if (!indexResponse.ok) {
|
||||
console.warn(
|
||||
'Could not fetch data-file-index.txt, possibly offline. Skipping default filesystem population.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// This is hardcoded. We know we must create the migrations
|
||||
// directory, it's not worth complicating the index to support
|
||||
// creating arbitrary folders.
|
||||
await mkdir('/migrations');
|
||||
await mkdir('/demo-budget');
|
||||
const index = await indexResponse.text();
|
||||
const files = index
|
||||
.split('\n')
|
||||
.map(name => name.trim())
|
||||
.filter(name => name !== '');
|
||||
const fetchFile = async url => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
files.map(async file => {
|
||||
const contents = await fetchFile(process.env.PUBLIC_URL + 'data/' + file);
|
||||
_writeFile('/' + file, contents);
|
||||
}),
|
||||
);
|
||||
// This is hardcoded. We know we must create the migrations
|
||||
// directory, it's not worth complicating the index to support
|
||||
// creating arbitrary folders.
|
||||
await mkdir('/migrations');
|
||||
await mkdir('/demo-budget');
|
||||
|
||||
await Promise.all(
|
||||
files.map(async file => {
|
||||
try {
|
||||
const contents = await fetchFile(
|
||||
process.env.PUBLIC_URL + 'data/' + file,
|
||||
);
|
||||
_writeFile('/' + file, contents);
|
||||
} catch (err) {
|
||||
console.warn(`Could not fetch data file ${file}:`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Could not populate default filesystem, possibly offline:',
|
||||
err,
|
||||
);
|
||||
// Create the required directories even if we can't fetch files
|
||||
try {
|
||||
await mkdir('/migrations');
|
||||
await mkdir('/demo-budget');
|
||||
} catch {
|
||||
// Directories might already exist, ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const populateFileHeirarchy = async function () {
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"workbox-precaching": "^7.3.0"
|
||||
"workbox-cacheable-response": "^7.3.0",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-expiration": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0",
|
||||
"workbox-routing": "^7.3.0",
|
||||
"workbox-strategies": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.1",
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
import { precacheAndRoute } from 'workbox-precaching';
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||
import type { WorkboxPlugin } from 'workbox-core';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||
import { NavigationRoute, registerRoute } from 'workbox-routing';
|
||||
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
|
||||
|
||||
// Service Worker Global Types
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_DISABLE_DEV_LOGS: boolean;
|
||||
__WB_MANIFEST: Array<{ url: string; revision: string | null }>;
|
||||
};
|
||||
|
||||
type PluginFile = {
|
||||
@@ -20,21 +26,167 @@ type PluginMessage = {
|
||||
|
||||
self.__WB_DISABLE_DEV_LOGS = true;
|
||||
|
||||
// Keep in sync with `workbox.ignoreURLParametersMatching` in
|
||||
// `packages/desktop-client/vite.config.mts`
|
||||
const PRECACHE_OPTIONS = {
|
||||
ignoreURLParametersMatching: [/^v$/] as RegExp[],
|
||||
};
|
||||
|
||||
// Injected by VitePWA
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
// Use empty array as fallback if __WB_MANIFEST is not injected
|
||||
const manifest = self.__WB_MANIFEST || [];
|
||||
console.log(`[SW Precache] Precaching ${manifest.length} assets:`, manifest);
|
||||
precacheAndRoute(manifest, PRECACHE_OPTIONS);
|
||||
|
||||
const appShellHandler = createHandlerBoundToURL('/index.html');
|
||||
|
||||
const navigationRoute = new NavigationRoute(appShellHandler, {
|
||||
denylist: [
|
||||
/^\/account\/.*$/,
|
||||
/^\/admin\/.*$/,
|
||||
/^\/secret\/.*$/,
|
||||
/^\/openid\/.*$/,
|
||||
/^\/plugins\/.*$/,
|
||||
],
|
||||
});
|
||||
|
||||
registerRoute(navigationRoute);
|
||||
|
||||
// Custom logging plugin for Workbox
|
||||
const loggingPlugin: WorkboxPlugin = {
|
||||
cacheWillUpdate: async ({ response, request }) => {
|
||||
console.log(
|
||||
`[SW Cache] Storing in cache: ${request.url} (status: ${response.status})`,
|
||||
);
|
||||
return response;
|
||||
},
|
||||
cachedResponseWillBeUsed: async ({ cacheName, request, cachedResponse }) => {
|
||||
if (cachedResponse) {
|
||||
console.log(`[SW Cache HIT] Retrieved from ${cacheName}: ${request.url}`);
|
||||
} else {
|
||||
console.log(`[SW Cache MISS] Not in ${cacheName}: ${request.url}`);
|
||||
}
|
||||
return cachedResponse ?? null;
|
||||
},
|
||||
fetchDidSucceed: async ({ request, response }) => {
|
||||
console.log(
|
||||
`[SW Network] Fetched successfully: ${request.url} (status: ${response.status})`,
|
||||
);
|
||||
return response;
|
||||
},
|
||||
fetchDidFail: async ({ request, error }) => {
|
||||
console.error(`[SW Network FAIL] Failed to fetch: ${request.url}`, error);
|
||||
throw error;
|
||||
},
|
||||
handlerDidError: async ({ request, error }) => {
|
||||
console.error(`[SW Handler ERROR] ${request.url}`, error);
|
||||
return new Response('Offline fallback', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Cache fonts with CacheFirst strategy
|
||||
registerRoute(
|
||||
({ request }) => request.destination === 'font',
|
||||
new CacheFirst({
|
||||
cacheName: 'fonts-cache',
|
||||
plugins: [
|
||||
loggingPlugin,
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
|
||||
maxEntries: 30,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Cache static assets (images, icons, manifests) with CacheFirst strategy
|
||||
registerRoute(
|
||||
({ request }) =>
|
||||
request.destination === 'image' ||
|
||||
request.url.endsWith('.webmanifest') ||
|
||||
request.url.endsWith('.ico') ||
|
||||
request.url.endsWith('.png'),
|
||||
new CacheFirst({
|
||||
cacheName: 'static-assets-cache',
|
||||
plugins: [
|
||||
loggingPlugin,
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
|
||||
maxEntries: 60,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Cache data files with NetworkFirst strategy (fallback to cache when offline)
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
url.pathname.includes('/data/') ||
|
||||
url.pathname.endsWith('data-file-index.txt'),
|
||||
new NetworkFirst({
|
||||
cacheName: 'data-files-cache',
|
||||
plugins: [
|
||||
loggingPlugin,
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
|
||||
maxEntries: 100,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const fileList = new Map<string, string>();
|
||||
|
||||
// Register a Workbox route for plugin-data requests
|
||||
// This ensures Workbox's router handles all requests, including plugin-data
|
||||
const pluginDataMatch = ({ url }: { url: URL }) => {
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
const pluginsIndex = pathSegments.indexOf('plugin-data');
|
||||
return pluginsIndex !== -1 && pathSegments[pluginsIndex + 1] !== undefined;
|
||||
};
|
||||
|
||||
registerRoute(pluginDataMatch, async ({ request: _request, url }) => {
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
const pluginsIndex = pathSegments.indexOf('plugin-data');
|
||||
const slugIndex = pluginsIndex + 1;
|
||||
const slug = pathSegments[slugIndex];
|
||||
const fileName =
|
||||
pathSegments.length > slugIndex + 1
|
||||
? pathSegments[slugIndex + 1].split('?')[0]
|
||||
: '';
|
||||
console.log(`[SW Plugin] Handling plugin request: ${url.pathname}`);
|
||||
const response = await handlePlugin(slug, fileName.replace('?import', ''));
|
||||
console.log(
|
||||
`[SW Plugin] Response for ${url.pathname}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
return response;
|
||||
});
|
||||
|
||||
// Log installation event
|
||||
self.addEventListener('install', (_event: ExtendableEvent) => {
|
||||
console.log('Plugins Worker installing...');
|
||||
console.log('[SW Lifecycle] Service Worker installing...');
|
||||
console.log('[SW Lifecycle] Version:', new Date().toISOString());
|
||||
});
|
||||
|
||||
// Log activation event
|
||||
self.addEventListener('activate', (_event: ExtendableEvent) => {
|
||||
console.log('[SW Lifecycle] Service Worker activated and claiming clients');
|
||||
self.clients.claim();
|
||||
|
||||
self.clients.matchAll().then(clients => {
|
||||
console.log(`[SW Lifecycle] Notifying ${clients.length} client(s)`);
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'service-worker-ready',
|
||||
@@ -50,28 +202,13 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event: FetchEvent) => {
|
||||
const url = new URL(event.request.url);
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean); // Split and remove empty segments
|
||||
|
||||
const pluginsIndex = pathSegments.indexOf('plugin-data');
|
||||
const slugIndex = pluginsIndex + 1;
|
||||
if (pluginsIndex !== -1 && pathSegments[slugIndex]) {
|
||||
const slug = pathSegments[slugIndex];
|
||||
const fileName =
|
||||
pathSegments.length > slugIndex + 1
|
||||
? pathSegments[slugIndex + 1].split('?')[0]
|
||||
: '';
|
||||
event.respondWith(handlePlugin(slug, fileName.replace('?import', '')));
|
||||
} else {
|
||||
event.respondWith(fetch(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
async function handlePlugin(slug: string, fileName: string): Promise<Response> {
|
||||
console.log(`[SW Plugin] Looking for plugin file: ${slug}/${fileName}`);
|
||||
|
||||
for (const key of fileList.keys()) {
|
||||
if (key.startsWith(`${slug}/`)) {
|
||||
if (key.endsWith(`/${fileName}`)) {
|
||||
console.log(`[SW Plugin Cache HIT] Found in memory: ${key}`);
|
||||
const content = fileList.get(key);
|
||||
const contentType = getContentType(fileName);
|
||||
return new Response(content, {
|
||||
@@ -81,8 +218,10 @@ async function handlePlugin(slug: string, fileName: string): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SW Plugin] Not in memory cache, requesting from client`);
|
||||
const clientsList = await self.clients.matchAll();
|
||||
if (clientsList.length === 0) {
|
||||
console.warn('[SW Plugin] No active clients available');
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No active clients to process' }),
|
||||
{
|
||||
@@ -93,20 +232,37 @@ async function handlePlugin(slug: string, fileName: string): Promise<Response> {
|
||||
}
|
||||
|
||||
const client = clientsList[0];
|
||||
console.log(`[SW Plugin] Requesting plugin files from client for: ${slug}`);
|
||||
|
||||
return new Promise<Response>(resolve => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = (messageEvent: MessageEvent<PluginFile[]>) => {
|
||||
const responseData = messageEvent.data as PluginFile[];
|
||||
|
||||
if (responseData && Array.isArray(responseData)) {
|
||||
console.log(
|
||||
`[SW Plugin] Received ${responseData.length} files from client for ${slug}`,
|
||||
);
|
||||
responseData.forEach(({ name, content }) => {
|
||||
fileList.set(`${slug}/${encodeURIComponent(name)}`, content);
|
||||
const key = `${slug}/${encodeURIComponent(name)}`;
|
||||
fileList.set(key, content);
|
||||
console.log(
|
||||
`[SW Plugin] Stored in memory: ${key} (${content.length} bytes)`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`[SW Plugin] Received invalid response data for ${slug}`,
|
||||
responseData,
|
||||
);
|
||||
}
|
||||
|
||||
const fileToCheck = fileName.length > 0 ? fileName : 'mf-manifest.json';
|
||||
|
||||
if (fileList.has(`${slug}/${fileToCheck}`)) {
|
||||
console.log(
|
||||
`[SW Plugin] Successfully found requested file: ${slug}/${fileToCheck}`,
|
||||
);
|
||||
let content = fileList.get(`${slug}/${fileToCheck}`)!;
|
||||
const contentType = getContentType(fileToCheck);
|
||||
const headers: Record<string, string> = { 'Content-Type': contentType };
|
||||
@@ -117,10 +273,13 @@ async function handlePlugin(slug: string, fileName: string): Promise<Response> {
|
||||
if (manifest.metaData?.publicPath) {
|
||||
manifest.metaData.publicPath = `/plugin-data/${slug}/`;
|
||||
content = JSON.stringify(manifest);
|
||||
console.log(
|
||||
`[SW Plugin] Rewrote publicPath in manifest for ${slug}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to parse manifest for publicPath rewrite:',
|
||||
`[SW Plugin] Failed to parse manifest for publicPath rewrite:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -132,6 +291,13 @@ async function handlePlugin(slug: string, fileName: string): Promise<Response> {
|
||||
|
||||
resolve(new Response(content, { headers }));
|
||||
} else {
|
||||
console.warn(
|
||||
`[SW Plugin] File not found after client response: ${slug}/${fileToCheck}`,
|
||||
);
|
||||
console.log(
|
||||
`[SW Plugin] Available files:`,
|
||||
Array.from(fileList.keys()).filter(k => k.startsWith(`${slug}/`)),
|
||||
);
|
||||
resolve(new Response('File not found', { status: 404 }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => {
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/plugin-service-worker.ts'),
|
||||
name: 'plugin_sw',
|
||||
formats: ['iife'],
|
||||
formats: ['es'],
|
||||
fileName: () => `plugin-sw.js`,
|
||||
},
|
||||
sourcemap: true,
|
||||
@@ -26,6 +26,11 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
mangle: false,
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.ts', '.json'],
|
||||
|
||||
7
upcoming-release-notes/6233.md
Normal file
7
upcoming-release-notes/6233.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Fix offline mode functionality for VitePWA by updating service worker integration and routing.
|
||||
|
||||
Reference in New Issue
Block a user