Compare commits

...

15 Commits

Author SHA1 Message Date
Michael Clark
922f9fd911 Merge branch 'master' into bugfix/plugins/fix-plugins-sw 2025-12-08 18:54:06 +00:00
lelemm
64165d4c4b lint 2025-12-07 04:08:22 -03:00
lelemm
710aa8ad08 update yarn.lock 2025-12-07 04:02:55 -03:00
lelemm
dad0ee062b linter 2025-12-07 04:00:45 -03:00
lelemm
b1a2501e83 lets see 2025-12-07 04:00:45 -03:00
lelemm
ef0ce7fc9d testing 2025-12-07 04:00:45 -03:00
lelemm
1eb5c50732 lock file updated 2025-12-07 04:00:45 -03:00
lelemm
62097f287a another test 2025-12-07 04:00:45 -03:00
lelemm
31140fc9b8 changes 2025-12-07 04:00:45 -03:00
lelemm
bc4e2238bb another test 2025-12-07 04:00:45 -03:00
lelemm
f8aae08784 testing offline mode 2025-12-07 04:00:45 -03:00
github-actions[bot]
d5042132b7 Add release notes for PR #6233 2025-12-07 04:00:45 -03:00
lelemm
59b7f374d2 update yarn.lock and lint fix 2025-12-07 04:00:45 -03:00
autofix-ci[bot]
912bd10367 [autofix.ci] apply automated fixes 2025-12-07 04:00:35 -03:00
lelemm
18b5429574 Attempt fix for offline mode 2025-12-07 04:00:35 -03:00
7 changed files with 1872 additions and 1432 deletions

View File

@@ -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(),

View File

@@ -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 () {

View File

@@ -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",

View File

@@ -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 }));
}
};

View File

@@ -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'],

View File

@@ -0,0 +1,7 @@
---
category: Bugfix
authors: [lelemm]
---
Fix offline mode functionality for VitePWA by updating service worker integration and routing.

2948
yarn.lock

File diff suppressed because it is too large Load Diff