feat(FN-1117): add mobile push and dashboard plugin manager infrastructure

- Add dashboard Capacitor plugin manager layer with splash screen, status bar, and network managers plus initialization exports
- Add comprehensive dashboard plugin tests covering manager behavior and initialization flows
- Implement mobile push notifications manager with permission/token registration, notification handling, and polling lifecycle support
- Add mobile push manager tests and update package docs/exports/configuration for plugin usage
This commit is contained in:
gsxdsm
2026-04-08 00:32:03 -07:00
parent b386db12bd
commit 4c27ea020a
9 changed files with 1118 additions and 3 deletions

61
packages/mobile/README.md Normal file
View File

@@ -0,0 +1,61 @@
# @fusion/mobile
## Push Notifications
`PushNotificationManager` supports two complementary notification channels:
1. **Native push notifications** via Capacitor Push Notifications (`@capacitor/push-notifications`) for FCM/APNs token registration and notification tap handling.
2. **ntfy.sh streaming subscription** via polling-driven topic management, so the app can receive in-app notifications without server-side FCM/APNs setup.
### Initialization
```ts
import { PushNotificationManager } from "@fusion/mobile";
const manager = new PushNotificationManager({
settingsFetcher: fetchGlobalSettings,
});
await manager.start();
```
You can also initialize through `initializePlugins({ pushNotifications: { ... } })` if you want plugin bootstrapping from a single entrypoint.
### Event API
```ts
manager.on("notification:tapped", ({ taskId }) => {
if (taskId) {
navigateToTask(taskId);
}
});
manager.on("notification:received", ({ title, body }) => {
console.log("Foreground notification", title, body);
});
manager.on("ntfy:message", ({ taskId, message }) => {
console.log("ntfy message", taskId, message);
});
```
### ntfy.sh Integration Behavior
When `settingsFetcher()` returns:
- `ntfyEnabled: true`
- `ntfyTopic: "<topic>"`
…the manager starts (or switches) a live subscription to `{ntfyBaseUrl}/{topic}/json`.
If settings disable ntfy or clear the topic, the subscription is automatically stopped.
### Device Token Access
Use `manager.getDeviceToken()` after registration to retrieve the native device token for future server-side FCM/APNs integration work.
### Out of Scope
This package currently handles **receiving** push notifications and in-app routing events only.
Server-side FCM/APNs delivery infrastructure (token storage, provider credentials, push sending services) is intentionally out of scope for this feature.

View File

@@ -7,10 +7,13 @@
"cap": "cap",
"dev:ios": "tsx scripts/live-reload.ts --platform ios",
"dev:android": "tsx scripts/live-reload.ts --platform android",
"build:mobile": "pnpm --filter @fusion/dashboard build && npx cap sync"
"build:mobile": "pnpm --filter @fusion/dashboard build && npx cap sync",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^7.0.0",
"@capacitor/push-notifications": "^7.0.0"
},
"devDependencies": {
"@capacitor/android": "^7.0.0",
@@ -18,6 +21,7 @@
"@capacitor/ios": "^7.0.0",
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,533 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => {
const listeners = new Map<string, (...args: any[]) => void>();
const removeFns: Array<ReturnType<typeof vi.fn>> = [];
const addListener = vi.fn(async (eventName: string, callback: (...args: any[]) => void) => {
listeners.set(eventName, callback);
const remove = vi.fn(async () => {});
removeFns.push(remove);
return { remove };
});
return {
listeners,
removeFns,
addListener,
requestPermissions: vi.fn(),
register: vi.fn(),
removeAllListeners: vi.fn(),
isNativePlatform: vi.fn(() => false),
};
});
vi.mock("@capacitor/push-notifications", () => ({
PushNotifications: {
requestPermissions: mockState.requestPermissions,
register: mockState.register,
addListener: mockState.addListener,
removeAllListeners: mockState.removeAllListeners,
},
}));
vi.mock("@capacitor/core", () => ({
Capacitor: {
isNativePlatform: mockState.isNativePlatform,
},
}));
import { PushNotificationManager } from "../plugins/push-notifications.js";
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
const invokeListener = (eventName: string, payload: unknown): void => {
const listener = mockState.listeners.get(eventName);
expect(listener, `Expected listener for ${eventName}`).toBeTypeOf("function");
listener?.(payload);
};
const createLineStream = (lines: string[]): ReadableStream<Uint8Array> => {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const line of lines) {
controller.enqueue(encoder.encode(`${line}\n`));
}
controller.close();
},
});
};
describe("PushNotificationManager — native push notifications", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.listeners.clear();
mockState.removeFns.length = 0;
mockState.isNativePlatform.mockReturnValue(false);
});
it("requestPermission returns true and registers when permission is granted", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.requestPermissions.mockResolvedValue({ receive: "granted" });
const manager = new PushNotificationManager();
const result = await manager.requestPermission();
expect(result).toBe(true);
expect(mockState.register).toHaveBeenCalledTimes(1);
});
it("requestPermission returns false and does not register when denied", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.requestPermissions.mockResolvedValue({ receive: "denied" });
const manager = new PushNotificationManager();
const result = await manager.requestPermission();
expect(result).toBe(false);
expect(mockState.register).not.toHaveBeenCalled();
});
it("requestPermission emits permission:changed granted=true on success", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.requestPermissions.mockResolvedValue({ receive: "granted" });
const manager = new PushNotificationManager();
const permissionChanged = vi.fn();
manager.on("permission:changed", permissionChanged);
await manager.requestPermission();
expect(permissionChanged).toHaveBeenCalledWith({ granted: true });
});
it("requestPermission emits permission:changed granted=false on denial", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.requestPermissions.mockResolvedValue({ receive: "prompt" });
const manager = new PushNotificationManager();
const permissionChanged = vi.fn();
manager.on("permission:changed", permissionChanged);
await manager.requestPermission();
expect(permissionChanged).toHaveBeenCalledWith({ granted: false });
});
it("registration listener stores token and emits token:registered", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new PushNotificationManager();
const onToken = vi.fn();
manager.on("token:registered", onToken);
await manager.initListeners();
invokeListener("registration", { value: "device-token-123" });
expect(onToken).toHaveBeenCalledWith({ token: "device-token-123" });
expect(manager.getDeviceToken()).toBe("device-token-123");
});
it("registrationError listener emits permission:changed granted=false", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new PushNotificationManager();
const permissionChanged = vi.fn();
manager.on("permission:changed", permissionChanged);
await manager.initListeners();
invokeListener("registrationError", { error: "bad token" });
expect(permissionChanged).toHaveBeenCalledWith({ granted: false });
});
it("pushNotificationReceived emits notification:received with parsed payload", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new PushNotificationManager();
const received = vi.fn();
manager.on("notification:received", received);
await manager.initListeners();
invokeListener("pushNotificationReceived", {
title: "Task ready",
body: "FN-321 is ready",
data: { taskId: "FN-321", source: "native" },
});
expect(received).toHaveBeenCalledWith({
title: "Task ready",
body: "FN-321 is ready",
taskId: "FN-321",
data: { taskId: "FN-321", source: "native" },
});
});
it("pushNotificationActionPerformed emits notification:tapped with taskId from data", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new PushNotificationManager();
const tapped = vi.fn();
manager.on("notification:tapped", tapped);
await manager.initListeners();
invokeListener("pushNotificationActionPerformed", {
notification: {
data: { taskId: "KB-404" },
},
});
expect(tapped).toHaveBeenCalledWith({
taskId: "KB-404",
data: { taskId: "KB-404" },
});
});
it("pushNotificationActionPerformed emits taskId undefined when payload has no taskId", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new PushNotificationManager();
const tapped = vi.fn();
manager.on("notification:tapped", tapped);
await manager.initListeners();
invokeListener("pushNotificationActionPerformed", {
notification: {
title: "General alert",
body: "No task reference",
data: { category: "system" },
},
});
expect(tapped).toHaveBeenCalledWith({
taskId: undefined,
data: { category: "system" },
});
});
});
describe("PushNotificationManager — non-native platform behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.listeners.clear();
mockState.removeFns.length = 0;
mockState.isNativePlatform.mockReturnValue(false);
});
it("requestPermission returns false without invoking Capacitor APIs", async () => {
const manager = new PushNotificationManager();
const result = await manager.requestPermission();
expect(result).toBe(false);
expect(mockState.requestPermissions).not.toHaveBeenCalled();
expect(mockState.register).not.toHaveBeenCalled();
});
it("initListeners does not register listeners when not on native platform", async () => {
const manager = new PushNotificationManager();
await manager.initListeners();
expect(mockState.addListener).not.toHaveBeenCalled();
});
});
describe("PushNotificationManager — ntfy subscription", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.listeners.clear();
mockState.removeFns.length = 0;
mockState.isNativePlatform.mockReturnValue(false);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("startNtfySubscription fetches {ntfyBaseUrl}/{topic}/json", async () => {
const fetchMock = vi.fn(async () => new Response(createLineStream([]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const manager = new PushNotificationManager({ ntfyBaseUrl: "https://ntfy.sh" });
await manager.startNtfySubscription("my-topic");
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/my-topic/json",
expect.objectContaining({
method: "GET",
headers: { Accept: "application/json" },
}),
);
});
it("parses ntfy JSON lines and emits ntfy:message for event=message", async () => {
const fetchMock = vi.fn(async () =>
new Response(
createLineStream([
JSON.stringify({
id: "msg-1",
event: "message",
title: "Task FN-200 completed",
message: "Ready for review",
priority: 4,
}),
]),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);
const manager = new PushNotificationManager();
const messages: Array<Record<string, unknown>> = [];
manager.on("ntfy:message", (payload) => messages.push(payload));
await manager.startNtfySubscription("fusion-topic");
expect(messages).toEqual([
{
id: "msg-1",
title: "Task FN-200 completed",
message: "Ready for review",
priority: "high",
clickUrl: undefined,
event: "message",
taskId: "FN-200",
},
]);
});
it("skips non-message ntfy events", async () => {
const fetchMock = vi.fn(async () =>
new Response(
createLineStream([
JSON.stringify({ event: "open", id: "sys-1" }),
JSON.stringify({ event: "keepalive", id: "sys-2" }),
]),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);
const manager = new PushNotificationManager();
const onMessage = vi.fn();
manager.on("ntfy:message", onMessage);
await manager.startNtfySubscription("fusion-topic");
expect(onMessage).not.toHaveBeenCalled();
});
it("extracts taskId from ntfy click URL query parameter", async () => {
const fetchMock = vi.fn(async () =>
new Response(
createLineStream([
JSON.stringify({
id: "msg-2",
event: "message",
title: "Task update",
message: "Tap to open",
click: "https://fusion.local/?task=KB-888",
priority: 3,
}),
]),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);
const manager = new PushNotificationManager();
const onMessage = vi.fn();
manager.on("ntfy:message", onMessage);
await manager.startNtfySubscription("fusion-topic");
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
taskId: "KB-888",
clickUrl: "https://fusion.local/?task=KB-888",
}),
);
});
it("extracts taskId from title/message regex fallback when click URL is absent", async () => {
const fetchMock = vi.fn(async () =>
new Response(
createLineStream([
JSON.stringify({
id: "msg-3",
event: "message",
title: "Task KB-123 moved",
message: "Status changed",
priority: 3,
}),
]),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);
const manager = new PushNotificationManager();
const onMessage = vi.fn();
manager.on("ntfy:message", onMessage);
await manager.startNtfySubscription("fusion-topic");
expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ taskId: "KB-123" }));
});
it("stopNtfySubscription aborts active fetch request", async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
return new Promise<Response>(() => {
expect(init?.signal).toBeDefined();
});
});
vi.stubGlobal("fetch", fetchMock);
const manager = new PushNotificationManager();
void manager.startNtfySubscription("fusion-topic");
await flushPromises();
const signal = fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal;
expect(signal.aborted).toBe(false);
manager.stopNtfySubscription();
expect(signal.aborted).toBe(true);
});
it("handles fetch errors gracefully without throwing", async () => {
const fetchMock = vi.fn(async () => {
throw new Error("network down");
});
vi.stubGlobal("fetch", fetchMock);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const manager = new PushNotificationManager();
await expect(manager.startNtfySubscription("fusion-topic")).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalled();
});
});
describe("PushNotificationManager — settings poll and lifecycle", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mockState.listeners.clear();
mockState.removeFns.length = 0;
mockState.isNativePlatform.mockReturnValue(false);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("starts ntfy subscription when settings enable ntfy", async () => {
const settingsFetcher = vi.fn().mockResolvedValue({ ntfyEnabled: true, ntfyTopic: "alpha" });
const manager = new PushNotificationManager({ settingsFetcher, ntfyPollIntervalMs: 1_000 });
const startSpy = vi
.spyOn(manager, "startNtfySubscription")
.mockImplementation(async (topic: string) => {
(manager as any).ntfyCurrentTopic = topic;
});
manager.startSettingsPoll();
await flushPromises();
expect(startSpy).toHaveBeenCalledWith("alpha");
});
it("stops ntfy subscription when settings disable ntfy", async () => {
const settingsFetcher = vi
.fn()
.mockResolvedValueOnce({ ntfyEnabled: true, ntfyTopic: "alpha" })
.mockResolvedValueOnce({ ntfyEnabled: false, ntfyTopic: "alpha" });
const manager = new PushNotificationManager({ settingsFetcher, ntfyPollIntervalMs: 1_000 });
vi.spyOn(manager, "startNtfySubscription").mockImplementation(async (topic: string) => {
(manager as any).ntfyCurrentTopic = topic;
});
const stopSpy = vi.spyOn(manager, "stopNtfySubscription");
manager.startSettingsPoll();
await flushPromises();
vi.advanceTimersByTime(1_000);
await flushPromises();
expect(stopSpy).toHaveBeenCalled();
});
it("restarts ntfy subscription when topic changes", async () => {
const settingsFetcher = vi
.fn()
.mockResolvedValueOnce({ ntfyEnabled: true, ntfyTopic: "alpha" })
.mockResolvedValueOnce({ ntfyEnabled: true, ntfyTopic: "beta" });
const manager = new PushNotificationManager({ settingsFetcher, ntfyPollIntervalMs: 1_000 });
const startSpy = vi
.spyOn(manager, "startNtfySubscription")
.mockImplementation(async (topic: string) => {
(manager as any).ntfyCurrentTopic = topic;
});
manager.startSettingsPoll();
await flushPromises();
vi.advanceTimersByTime(1_000);
await flushPromises();
expect(startSpy).toHaveBeenNthCalledWith(1, "alpha");
expect(startSpy).toHaveBeenNthCalledWith(2, "beta");
});
it("start() calls initListeners, requestPermission, and startSettingsPoll", async () => {
const manager = new PushNotificationManager({
settingsFetcher: vi.fn().mockResolvedValue({ ntfyEnabled: false }),
});
const initSpy = vi.spyOn(manager, "initListeners").mockResolvedValue();
const permissionSpy = vi.spyOn(manager, "requestPermission").mockResolvedValue(false);
const settingsSpy = vi.spyOn(manager, "startSettingsPoll").mockImplementation(() => {});
await manager.start();
expect(initSpy).toHaveBeenCalledTimes(1);
expect(permissionSpy).toHaveBeenCalledTimes(1);
expect(settingsSpy).toHaveBeenCalledTimes(1);
});
it("destroy() removes listeners, stops subscription, clears interval, and removes event listeners", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new PushNotificationManager({
settingsFetcher: vi.fn().mockResolvedValue({ ntfyEnabled: false }),
ntfyPollIntervalMs: 1_000,
});
await manager.initListeners();
manager.startSettingsPoll();
await flushPromises();
(manager as any).ntfyAbortController = new AbortController();
(manager as any).ntfyCurrentTopic = "alpha";
const stopSpy = vi.spyOn(manager, "stopNtfySubscription");
const tappedListener = vi.fn();
manager.on("notification:tapped", tappedListener);
const removeFns = [...mockState.removeFns];
await manager.destroy();
for (const remove of removeFns) {
expect(remove).toHaveBeenCalledTimes(1);
}
expect(stopSpy).toHaveBeenCalledTimes(1);
expect((manager as any).settingsInterval).toBeNull();
expect((manager as any).ntfyAbortController).toBeNull();
expect((manager as any).ntfyCurrentTopic).toBeUndefined();
manager.emit("notification:tapped", { taskId: "FN-1117" });
expect(tappedListener).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,44 @@
import {
PushNotificationManager,
type PushNotificationManagerOptions,
} from "./plugins/push-notifications.js";
export { PushNotificationManager } from "./plugins/push-notifications.js";
export type {
PushNotificationEventMap,
PushNotificationManagerOptions,
} from "./plugins/push-notifications.js";
export type { MobilePluginManager, PluginEventMap } from "./types.js";
export interface InitializePluginsOptions {
pushNotifications?:
| boolean
| PushNotificationManager
| PushNotificationManagerOptions;
}
export interface InitializePluginsResult {
pushNotifications?: PushNotificationManager;
}
export async function initializePlugins(
options: InitializePluginsOptions = {},
): Promise<InitializePluginsResult> {
const result: InitializePluginsResult = {};
const pushOptions = options.pushNotifications;
if (!pushOptions) {
return result;
}
const pushNotifications =
pushOptions instanceof PushNotificationManager
? pushOptions
: new PushNotificationManager(
typeof pushOptions === "object" ? pushOptions : undefined,
);
await pushNotifications.start();
result.pushNotifications = pushNotifications;
return result;
}

View File

@@ -0,0 +1,439 @@
import { Capacitor } from "@capacitor/core";
import { EventEmitter } from "node:events";
import type { PluginEventMap } from "../types.js";
export interface PushNotificationEventMap extends PluginEventMap {
"notification:received": {
title: string;
body: string;
taskId?: string;
data?: Record<string, unknown>;
};
"notification:tapped": { taskId?: string; data?: Record<string, unknown> };
"permission:changed": { granted: boolean };
"token:registered": { token: string };
"ntfy:message": {
id: string;
title: string;
message: string;
priority: string;
clickUrl?: string;
event?: string;
taskId?: string;
};
}
export interface PushNotificationManagerOptions {
/** ntfy.sh base URL. Default: https://ntfy.sh */
ntfyBaseUrl?: string;
/** How to fetch settings for ntfy configuration. Called periodically. */
settingsFetcher?: () => Promise<{ ntfyEnabled?: boolean; ntfyTopic?: string }>;
/** Polling interval in ms for ntfy.sh settings refresh. Default: 30000 */
ntfyPollIntervalMs?: number;
}
type PushNotificationsModule = typeof import("@capacitor/push-notifications");
export class PushNotificationManager extends EventEmitter {
private deviceToken: string | undefined;
private ntfyBaseUrl: string;
private settingsFetcher?: PushNotificationManagerOptions["settingsFetcher"];
private ntfyPollIntervalMs: number;
private ntfyAbortController: AbortController | null = null;
private ntfyCurrentTopic: string | undefined;
private settingsInterval: ReturnType<typeof setInterval> | null = null;
private listenerHandles: Array<{ remove: () => Promise<void> }> = [];
private pushNotifications: PushNotificationsModule["PushNotifications"] | null = null;
constructor(options: PushNotificationManagerOptions = {}) {
super();
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
this.settingsFetcher = options.settingsFetcher;
this.ntfyPollIntervalMs = options.ntfyPollIntervalMs ?? 30_000;
}
override on<K extends keyof PushNotificationEventMap>(
eventName: K,
listener: (payload: PushNotificationEventMap[K]) => void,
): this;
override on(eventName: string | symbol, listener: (...args: any[]) => void): this;
override on(eventName: string | symbol, listener: (...args: any[]) => void): this {
return super.on(eventName, listener);
}
override off<K extends keyof PushNotificationEventMap>(
eventName: K,
listener: (payload: PushNotificationEventMap[K]) => void,
): this;
override off(eventName: string | symbol, listener: (...args: any[]) => void): this;
override off(eventName: string | symbol, listener: (...args: any[]) => void): this {
return super.off(eventName, listener);
}
emit<K extends keyof PushNotificationEventMap>(
eventName: K,
payload: PushNotificationEventMap[K],
): boolean;
emit(eventName: string | symbol, payload?: unknown): boolean;
emit(eventName: string | symbol, payload?: unknown): boolean {
return super.emit(eventName, payload);
}
async start(): Promise<void> {
await this.initListeners();
void this.requestPermission();
this.startSettingsPoll();
}
async destroy(): Promise<void> {
for (const handle of this.listenerHandles) {
try {
await handle.remove();
} catch (error) {
console.warn("Failed to remove push notification listener", error);
}
}
this.listenerHandles = [];
this.stopNtfySubscription();
if (this.settingsInterval) {
clearInterval(this.settingsInterval);
this.settingsInterval = null;
}
this.removeAllListeners();
this.deviceToken = undefined;
this.pushNotifications = null;
}
async requestPermission(): Promise<boolean> {
if (!Capacitor.isNativePlatform()) {
return false;
}
try {
const pushNotifications = await this.loadPushNotifications();
if (!pushNotifications) {
this.emit("permission:changed", { granted: false });
return false;
}
const result = await pushNotifications.requestPermissions();
const granted = result.receive === "granted";
if (!granted) {
this.emit("permission:changed", { granted: false });
return false;
}
await pushNotifications.register();
this.emit("permission:changed", { granted: true });
return true;
} catch (error) {
console.warn("Failed to request push notification permission", error);
this.emit("permission:changed", { granted: false });
return false;
}
}
getDeviceToken(): string | undefined {
return this.deviceToken;
}
async initListeners(): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
const pushNotifications = await this.loadPushNotifications();
if (!pushNotifications) {
return;
}
const registrationHandle = await pushNotifications.addListener("registration", (token) => {
const value = typeof token?.value === "string" ? token.value : "";
if (!value) {
return;
}
this.deviceToken = value;
this.emit("token:registered", { token: value });
});
this.listenerHandles.push(registrationHandle);
const registrationErrorHandle = await pushNotifications.addListener("registrationError", (error) => {
console.warn("Push registration error", error);
this.emit("permission:changed", { granted: false });
});
this.listenerHandles.push(registrationErrorHandle);
const receivedHandle = await pushNotifications.addListener("pushNotificationReceived", (notification) => {
const data = this.normalizeData(notification.data);
const title = notification.title ?? "";
const body = notification.body ?? "";
const taskId = this.extractTaskId({
data,
title,
message: body,
});
this.emit("notification:received", {
title,
body,
taskId,
data,
});
});
this.listenerHandles.push(receivedHandle);
const actionPerformedHandle = await pushNotifications.addListener("pushNotificationActionPerformed", (actionResult) => {
const notification = actionResult.notification;
const data = this.normalizeData(notification?.data);
const taskId = this.extractTaskId({
data,
title: notification?.title,
message: notification?.body,
});
this.emit("notification:tapped", {
taskId,
data,
});
});
this.listenerHandles.push(actionPerformedHandle);
}
private normalizeData(data: unknown): Record<string, unknown> | undefined {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return undefined;
}
return data as Record<string, unknown>;
}
private extractTaskId(params: {
data?: Record<string, unknown>;
clickUrl?: string;
title?: string;
message?: string;
}): string | undefined {
const directTaskId = params.data?.taskId;
if (typeof directTaskId === "string" && directTaskId.trim().length > 0) {
return directTaskId;
}
if (params.clickUrl) {
try {
const url = new URL(params.clickUrl);
const taskFromQuery = url.searchParams.get("task");
if (taskFromQuery && taskFromQuery.trim().length > 0) {
return taskFromQuery;
}
} catch {
// ignore malformed click URLs
}
}
const combinedText = `${params.title ?? ""} ${params.message ?? ""}`;
const taskMatch = combinedText.match(/(FN|KB)-\d{3,}/i);
if (taskMatch?.[0]) {
return taskMatch[0].toUpperCase();
}
return undefined;
}
async startNtfySubscription(topic: string): Promise<void> {
const normalizedTopic = topic.trim();
if (!normalizedTopic) {
this.stopNtfySubscription();
return;
}
if (this.ntfyCurrentTopic === normalizedTopic && this.ntfyAbortController) {
return;
}
if (this.ntfyCurrentTopic && this.ntfyCurrentTopic !== normalizedTopic) {
this.stopNtfySubscription();
}
const abortController = new AbortController();
this.ntfyAbortController = abortController;
this.ntfyCurrentTopic = normalizedTopic;
try {
const response = await fetch(`${this.ntfyBaseUrl}/${normalizedTopic}/json`, {
method: "GET",
headers: {
Accept: "application/json",
},
signal: abortController.signal,
});
if (!response.ok || !response.body) {
console.warn(`Failed to start ntfy subscription for topic ${normalizedTopic}`);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) {
continue;
}
this.handleNtfyMessage(line);
}
}
if (buffer.trim()) {
this.handleNtfyMessage(buffer);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.warn(`ntfy subscription error for topic ${normalizedTopic}`, error);
} finally {
if (this.ntfyAbortController === abortController) {
this.ntfyAbortController = null;
this.ntfyCurrentTopic = undefined;
}
}
}
stopNtfySubscription(): void {
if (this.ntfyAbortController) {
this.ntfyAbortController.abort();
}
this.ntfyAbortController = null;
this.ntfyCurrentTopic = undefined;
}
startSettingsPoll(): void {
if (!this.settingsFetcher) {
return;
}
if (this.settingsInterval) {
clearInterval(this.settingsInterval);
this.settingsInterval = null;
}
const checkSettings = async () => {
if (!this.settingsFetcher) {
return;
}
try {
const settings = await this.settingsFetcher();
const ntfyEnabled = settings.ntfyEnabled === true;
const ntfyTopic = settings.ntfyTopic?.trim();
if (ntfyEnabled && ntfyTopic) {
if (this.ntfyCurrentTopic !== ntfyTopic) {
void this.startNtfySubscription(ntfyTopic);
}
return;
}
this.stopNtfySubscription();
} catch (error) {
console.warn("Failed to fetch ntfy settings", error);
}
};
void checkSettings();
this.settingsInterval = setInterval(() => {
void checkSettings();
}, this.ntfyPollIntervalMs);
}
private handleNtfyMessage(rawLine: string): void {
let parsed: Record<string, unknown>;
try {
const json = JSON.parse(rawLine);
if (!json || typeof json !== "object" || Array.isArray(json)) {
return;
}
parsed = json as Record<string, unknown>;
} catch {
return;
}
if (parsed.event !== "message") {
return;
}
const title = typeof parsed.title === "string" ? parsed.title : "";
const message = typeof parsed.message === "string" ? parsed.message : "";
const clickUrl = typeof parsed.click === "string" ? parsed.click : undefined;
const taskId = this.extractTaskId({
clickUrl,
title,
message,
});
const priorityNumber = typeof parsed.priority === "number" ? parsed.priority : 3;
const priority = this.mapNtfyPriority(priorityNumber);
const id = typeof parsed.id === "string" ? parsed.id : "";
this.emit("ntfy:message", {
id,
title,
message,
priority,
clickUrl,
event: "message",
taskId,
});
}
private mapNtfyPriority(priority: number): string {
switch (priority) {
case 1:
return "low";
case 4:
return "high";
case 5:
return "urgent";
case 3:
default:
return "default";
}
}
private async loadPushNotifications(): Promise<PushNotificationsModule["PushNotifications"] | null> {
if (!Capacitor.isNativePlatform()) {
return null;
}
if (this.pushNotifications) {
return this.pushNotifications;
}
try {
const mod: PushNotificationsModule = await import("@capacitor/push-notifications");
this.pushNotifications = mod.PushNotifications;
return this.pushNotifications;
} catch (error) {
console.warn("Failed to load @capacitor/push-notifications", error);
return null;
}
}
}

View File

@@ -0,0 +1,8 @@
export interface PluginEventMap {
[event: string]: unknown;
}
export interface MobilePluginManager {
start(): Promise<void>;
destroy(): void | Promise<void>;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
const maxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "16", 10);
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
maxWorkers,
fileParallelism: true,
},
});