Files
webmail/lib/offline/shell.ts
T
Linus Rath c65d18eaae feat: open mail, calendar and contact files from the OS and share into a new draft
The installed app only handled mailto: and webcal: launches. The launch
consumer dropped files, and nothing could hand a File to the composer or
the import dialogs, because the pending mailto is JSON in sessionStorage.

- manifest: `shortcuts` named in the browser's language, `file_handlers`
  for .eml, .ics and .vcf, and `share_target`, all with the base path and
  per-domain branding. Lite's manifest is unchanged.
- `lib/launch-handoff.ts`: the one place files and links wait, in memory
  and in the Cache API, so they survive a sign-in that leaves the page.
  The desktop bridge can call the same functions, since launchQueue never
  fires in Electron. Sign-out clears it.
- launch consumer: routes files by type, and takes a shortcut to an open
  window, which focus-existing would otherwise only focus.
- .eml opens in a new host for the preview, also before sign-in, with the
  strictest device policy deciding about saving. .ics and .vcf open their
  import dialogs through a new `initialFile` prop. A .ics needs the server,
  so offline the dialog says so and carries on once back online.
- composer: `initialFiles` goes through `addFiles`, so plugin vetoes and
  size limits still apply, also from a Pro compose tab.
- `sw.js` takes the share POST before the offline check and keeps the files
  for the page. Without a worker, `/protocol/share` passes text and links
  on and the page says the files are lost. `protocol` joins the app
  sections and the handler pages join the shell, so a mailto: launch
  offline no longer gets a network error.

Browsers without launchQueue or a service worker behave as before.
2026-11-03 05:26:01 +01:00

60 lines
2.3 KiB
TypeScript

/**
* The app shell for offline mode (#523): tells the service worker
* (public/sw.js) to keep this build's pages and files while any account
* keeps mail on this device, and to drop them when none does.
*/
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { currentBuildId, SW_SCOPE } from '@/lib/app-update';
/** The app's pages kept for a start without the network. */
const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal'];
/**
* The files of this tab's build. After a deploy the server lists the new
* build's files: nothing then, rather than another build's files under this
* build's name.
*/
export async function shellFiles(): Promise<string[]> {
try {
const res = await apiFetch('/api/offline/shell', { cache: 'no-store' });
if (!res.ok) return [];
const data = (await res.json()) as { files?: unknown; buildId?: unknown };
if (data.buildId !== currentBuildId()) return [];
return Array.isArray(data.files) ? data.files.filter((f): f is string => typeof f === 'string') : [];
} catch {
return [];
}
}
async function activeWorker(): Promise<ServiceWorker | null> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null;
try {
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
return registration?.active ?? navigator.serviceWorker.controller ?? null;
} catch {
return null;
}
}
export async function tellServiceWorker(on: boolean): Promise<void> {
const worker = await activeWorker();
if (!worker) return;
const files = on ? await shellFiles() : [];
// Without this build's files (a deploy happened), not its pages either:
// they would be the new build's.
const urls = on && files.length > 0 ? [...SHELL_PAGES.map((p) => withBasePath(p)), ...files] : [];
worker.postMessage({ type: 'offline-shell', on, urls, buildId: currentBuildId() });
}
/** The service worker's copies of the shell, from the page (also when no worker runs). */
export async function deleteShellCaches(): Promise<void> {
if (typeof caches === 'undefined') return;
try {
const keys = await caches.keys();
await Promise.all(keys.filter((key) => key.startsWith('bulwark-shell-')).map((key) => caches.delete(key)));
} catch {
/* no Cache API */
}
}