feat(FN-1118): add mobile sharing and deep-link managers
- Implement ShareManager with native Capacitor sharing, web share fallback, and clipboard fallback plus typed share lifecycle events - Implement DeepLinkManager with native appUrlOpen and browser hash listeners, URL parsing for fusion schemes and universal links, and error events - Wire plugin exports and initializePlugins support for share/deepLinks options, and configure Capacitor iOS/Android fusion URL schemes - Add comprehensive Vitest coverage for sharing and deep-link behavior and document usage in the mobile package README
This commit is contained in:
325
packages/mobile/src/__tests__/deep-links.test.ts
Normal file
325
packages/mobile/src/__tests__/deep-links.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type AppUrlOpenListener = (event: { url: string }) => void;
|
||||
|
||||
const mockState = vi.hoisted(() => {
|
||||
const state: {
|
||||
isNativePlatform: ReturnType<typeof vi.fn>;
|
||||
addListener: ReturnType<typeof vi.fn>;
|
||||
appListenerRemove: ReturnType<typeof vi.fn>;
|
||||
appUrlOpenListener?: AppUrlOpenListener;
|
||||
} = {
|
||||
isNativePlatform: vi.fn(() => false),
|
||||
addListener: vi.fn(),
|
||||
appListenerRemove: vi.fn(async () => {}),
|
||||
appUrlOpenListener: undefined,
|
||||
};
|
||||
|
||||
state.addListener.mockImplementation(
|
||||
async (eventName: string, callback: AppUrlOpenListener) => {
|
||||
if (eventName === "appUrlOpen") {
|
||||
state.appUrlOpenListener = callback;
|
||||
}
|
||||
return { remove: state.appListenerRemove };
|
||||
},
|
||||
);
|
||||
|
||||
return state;
|
||||
});
|
||||
|
||||
vi.mock("@capacitor/core", () => ({
|
||||
Capacitor: {
|
||||
isNativePlatform: mockState.isNativePlatform,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@capacitor/app", () => ({
|
||||
App: {
|
||||
addListener: mockState.addListener,
|
||||
},
|
||||
}));
|
||||
|
||||
import { DeepLinkManager } from "../plugins/deep-links.js";
|
||||
|
||||
const setupWindowMock = () => {
|
||||
const listeners = new Map<string, (event: Event) => void>();
|
||||
const location = { hash: "" };
|
||||
const addEventListener = vi.fn((event: string, handler: (evt: Event) => void) => {
|
||||
listeners.set(event, handler);
|
||||
});
|
||||
const removeEventListener = vi.fn((event: string, handler: (evt: Event) => void) => {
|
||||
const existing = listeners.get(event);
|
||||
if (existing === handler) {
|
||||
listeners.delete(event);
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
location,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
});
|
||||
|
||||
return {
|
||||
listeners,
|
||||
location,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
};
|
||||
};
|
||||
|
||||
describe("DeepLinkManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.isNativePlatform.mockReturnValue(false);
|
||||
mockState.appUrlOpenListener = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("URL parsing: fusion://task/FN-123", () => {
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
const payload = manager.handleUrl("fusion://task/FN-123");
|
||||
|
||||
expect(payload).toEqual({
|
||||
url: "fusion://task/FN-123",
|
||||
target: "task",
|
||||
taskId: "FN-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("URL parsing: fusion://project/my-project", () => {
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
const payload = manager.handleUrl("fusion://project/my-project");
|
||||
|
||||
expect(payload).toEqual({
|
||||
url: "fusion://project/my-project",
|
||||
target: "project",
|
||||
projectId: "my-project",
|
||||
});
|
||||
});
|
||||
|
||||
it("URL parsing: fusion://project/my-project/task/FN-123", () => {
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
const payload = manager.handleUrl("fusion://project/my-project/task/FN-123");
|
||||
|
||||
expect(payload).toEqual({
|
||||
url: "fusion://project/my-project/task/FN-123",
|
||||
target: "project",
|
||||
projectId: "my-project",
|
||||
taskId: "FN-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("URL parsing: fusion://settings", () => {
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
const payload = manager.handleUrl("fusion://settings");
|
||||
|
||||
expect(payload).toEqual({
|
||||
url: "fusion://settings",
|
||||
target: "settings",
|
||||
});
|
||||
});
|
||||
|
||||
it("URL parsing: fusion://agents", () => {
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
const payload = manager.handleUrl("fusion://agents");
|
||||
|
||||
expect(payload).toEqual({
|
||||
url: "fusion://agents",
|
||||
target: "agents",
|
||||
});
|
||||
});
|
||||
|
||||
it("URL parsing: fusion://task/FN-123?tab=workflow", () => {
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
const payload = manager.handleUrl("fusion://task/FN-123?tab=workflow");
|
||||
|
||||
expect(payload).toEqual({
|
||||
url: "fusion://task/FN-123?tab=workflow",
|
||||
target: "task",
|
||||
taskId: "FN-123",
|
||||
params: { tab: "workflow" },
|
||||
});
|
||||
});
|
||||
|
||||
it("URL parsing: universal link https://app.fusion.dev/?task=FN-123", () => {
|
||||
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
|
||||
|
||||
const payload = manager.handleUrl("https://app.fusion.dev/?task=FN-123");
|
||||
|
||||
expect(payload).toEqual({
|
||||
url: "https://app.fusion.dev/?task=FN-123",
|
||||
taskId: "FN-123",
|
||||
projectId: undefined,
|
||||
target: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("URL parsing: universal link from unrecognized host emits deeplink:error", () => {
|
||||
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
|
||||
const onError = vi.fn();
|
||||
manager.on("deeplink:error", onError);
|
||||
|
||||
const payload = manager.handleUrl("https://untrusted.example/?task=FN-123");
|
||||
|
||||
expect(payload).toBeNull();
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://untrusted.example/?task=FN-123",
|
||||
error: expect.any(Error),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("URL parsing: malformed URL emits deeplink:error and handleUrl returns null", () => {
|
||||
const manager = new DeepLinkManager();
|
||||
const onError = vi.fn();
|
||||
manager.on("deeplink:error", onError);
|
||||
|
||||
const payload = manager.handleUrl("not a url");
|
||||
|
||||
expect(payload).toBeNull();
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "not a url",
|
||||
error: expect.any(Error),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("URL parsing: original URL preserved in url field of all payloads", () => {
|
||||
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
|
||||
|
||||
const customPayload = manager.handleUrl("fusion://task/FN-321");
|
||||
const universalPayload = manager.handleUrl(
|
||||
"https://app.fusion.dev/?task=FN-654&target=task",
|
||||
);
|
||||
|
||||
expect(customPayload?.url).toBe("fusion://task/FN-321");
|
||||
expect(universalPayload?.url).toBe(
|
||||
"https://app.fusion.dev/?task=FN-654&target=task",
|
||||
);
|
||||
});
|
||||
|
||||
it("Native listener: initialize() registers App.addListener(appUrlOpen) on native platform", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(mockState.addListener).toHaveBeenCalledWith("appUrlOpen", expect.any(Function));
|
||||
});
|
||||
|
||||
it("Native listener: incoming appUrlOpen event is parsed and emits deeplink:received", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new DeepLinkManager();
|
||||
const onReceived = vi.fn();
|
||||
manager.on("deeplink:received", onReceived);
|
||||
|
||||
await manager.initialize();
|
||||
mockState.appUrlOpenListener?.({ url: "fusion://task/FN-900" });
|
||||
|
||||
expect(onReceived).toHaveBeenCalledWith({
|
||||
url: "fusion://task/FN-900",
|
||||
target: "task",
|
||||
taskId: "FN-900",
|
||||
});
|
||||
});
|
||||
|
||||
it("Native listener: initialize() does NOT register App listener on non-native platform", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(false);
|
||||
const manager = new DeepLinkManager();
|
||||
setupWindowMock();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(mockState.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Browser listener: initialize() registers hashchange listener on non-native platform", async () => {
|
||||
const windowMock = setupWindowMock();
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(windowMock.addEventListener).toHaveBeenCalledWith("hashchange", expect.any(Function));
|
||||
});
|
||||
|
||||
it("Browser listener: #deeplink=fusion://task/FN-123 hash change triggers deeplink:received event", async () => {
|
||||
const windowMock = setupWindowMock();
|
||||
const manager = new DeepLinkManager();
|
||||
const onReceived = vi.fn();
|
||||
manager.on("deeplink:received", onReceived);
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
windowMock.location.hash = `#deeplink=${encodeURIComponent("fusion://task/FN-123")}`;
|
||||
const hashHandler = windowMock.listeners.get("hashchange");
|
||||
hashHandler?.(new Event("hashchange"));
|
||||
|
||||
expect(onReceived).toHaveBeenCalledWith({
|
||||
url: "fusion://task/FN-123",
|
||||
target: "task",
|
||||
taskId: "FN-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("Lifecycle: initialize() is idempotent (second call is no-op)", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
await manager.initialize();
|
||||
await manager.initialize();
|
||||
|
||||
expect(mockState.addListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Lifecycle: getScheme() returns the configured scheme string", () => {
|
||||
const manager = new DeepLinkManager({ scheme: "fusion-custom://" });
|
||||
|
||||
expect(manager.getScheme()).toBe("fusion-custom://");
|
||||
});
|
||||
|
||||
it("Lifecycle: destroy() removes Capacitor App listener via handle.remove()", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
await manager.initialize();
|
||||
await manager.destroy();
|
||||
|
||||
expect(mockState.appListenerRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Lifecycle: destroy() removes browser hashchange listener", async () => {
|
||||
const windowMock = setupWindowMock();
|
||||
const manager = new DeepLinkManager();
|
||||
|
||||
await manager.initialize();
|
||||
await manager.destroy();
|
||||
|
||||
expect(windowMock.removeEventListener).toHaveBeenCalledWith(
|
||||
"hashchange",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("Lifecycle: destroy() removes all EventEmitter listeners", async () => {
|
||||
const manager = new DeepLinkManager();
|
||||
const onReceived = vi.fn();
|
||||
manager.on("deeplink:received", onReceived);
|
||||
|
||||
await manager.destroy();
|
||||
|
||||
manager.emit("deeplink:received", { url: "fusion://task/FN-001" });
|
||||
expect(onReceived).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
245
packages/mobile/src/__tests__/share.test.ts
Normal file
245
packages/mobile/src/__tests__/share.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
isNativePlatform: vi.fn(() => false),
|
||||
nativeShare: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@capacitor/core", () => ({
|
||||
Capacitor: {
|
||||
isNativePlatform: mockState.isNativePlatform,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@capacitor/share", () => ({
|
||||
Share: {
|
||||
share: mockState.nativeShare,
|
||||
},
|
||||
}));
|
||||
|
||||
import { ShareManager } from "../plugins/share.js";
|
||||
|
||||
describe("ShareManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.isNativePlatform.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("Native share: Share.share() called with correct title, text (truncated), and deep link URL", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||
const manager = new ShareManager();
|
||||
const longDescription = "x".repeat(250);
|
||||
|
||||
await manager.shareTask({ id: "FN-123", description: longDescription });
|
||||
|
||||
expect(mockState.nativeShare).toHaveBeenCalledWith({
|
||||
title: "Task FN-123",
|
||||
text: `${"x".repeat(200)}...`,
|
||||
url: "fusion://task/FN-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("Native share: emits share:success and returns true when share completes with activityType", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.nativeShare.mockResolvedValue({ activityType: "mail" });
|
||||
const manager = new ShareManager();
|
||||
const onSuccess = vi.fn();
|
||||
manager.on("share:success", onSuccess);
|
||||
|
||||
const result = await manager.shareTask({
|
||||
id: "FN-123",
|
||||
title: "Test Task",
|
||||
description: "Short description",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalledWith({ taskId: "FN-123" });
|
||||
});
|
||||
|
||||
it("Native share: emits share:cancelled and returns false when activityType is undefined", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.nativeShare.mockResolvedValue({});
|
||||
const manager = new ShareManager();
|
||||
const onCancelled = vi.fn();
|
||||
manager.on("share:cancelled", onCancelled);
|
||||
|
||||
const result = await manager.shareTask({
|
||||
id: "FN-124",
|
||||
description: "Short description",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(onCancelled).toHaveBeenCalledWith({ taskId: "FN-124" });
|
||||
});
|
||||
|
||||
it("Native share: emits share:error and returns false when Share.share() throws", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.nativeShare.mockRejectedValue(new Error("share failed"));
|
||||
const manager = new ShareManager();
|
||||
const onError = vi.fn();
|
||||
manager.on("share:error", onError);
|
||||
|
||||
const result = await manager.shareTask({
|
||||
id: "FN-125",
|
||||
description: "Short description",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: "FN-125",
|
||||
error: expect.any(Error),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("Native share: uses task.title when provided, falls back to Task {id} when title is undefined", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||
const manager = new ShareManager();
|
||||
|
||||
await manager.shareTask({ id: "FN-126", title: "Explicit Title", description: "Body" });
|
||||
await manager.shareTask({ id: "FN-127", description: "Body" });
|
||||
|
||||
expect(mockState.nativeShare).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ title: "Explicit Title" }),
|
||||
);
|
||||
expect(mockState.nativeShare).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ title: "Task FN-127" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("Native share: constructs correct deep link URL fusion://task/{id}", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||
const manager = new ShareManager();
|
||||
|
||||
await manager.shareTask({ id: "KB-777", description: "Task body" });
|
||||
|
||||
expect(mockState.nativeShare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "fusion://task/KB-777" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("Native share: respects custom deepLinkBaseUrl option", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||
const manager = new ShareManager({ deepLinkBaseUrl: "fusion://project/demo/task/" });
|
||||
|
||||
await manager.shareTask({ id: "FN-201", description: "Task body" });
|
||||
|
||||
expect(mockState.nativeShare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "fusion://project/demo/task/FN-201" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("Native share: truncates description longer than 200 chars and appends ...", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
|
||||
const manager = new ShareManager();
|
||||
|
||||
await manager.shareTask({ id: "FN-202", description: "a".repeat(201) });
|
||||
|
||||
expect(mockState.nativeShare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: `${"a".repeat(200)}...` }),
|
||||
);
|
||||
});
|
||||
|
||||
it("Web fallback: calls navigator.share() when not native but Web Share API is available", async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", {
|
||||
share,
|
||||
clipboard: {
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new ShareManager();
|
||||
const result = await manager.shareTask({ id: "FN-203", description: "Body" });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(share).toHaveBeenCalledWith({
|
||||
title: "Task FN-203",
|
||||
text: "Body",
|
||||
url: "fusion://task/FN-203",
|
||||
});
|
||||
});
|
||||
|
||||
it("Web fallback: emits share:cancelled when navigator.share() rejects with AbortError", async () => {
|
||||
const share = vi.fn().mockRejectedValue({ name: "AbortError" });
|
||||
vi.stubGlobal("navigator", {
|
||||
share,
|
||||
clipboard: {
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new ShareManager();
|
||||
const onCancelled = vi.fn();
|
||||
manager.on("share:cancelled", onCancelled);
|
||||
|
||||
const result = await manager.shareTask({ id: "FN-204", description: "Body" });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(onCancelled).toHaveBeenCalledWith({ taskId: "FN-204" });
|
||||
});
|
||||
|
||||
it("Clipboard fallback: copies deep link URL via navigator.clipboard.writeText() when neither native nor Web Share API", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", {
|
||||
clipboard: {
|
||||
writeText,
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new ShareManager();
|
||||
const result = await manager.shareTask({ id: "FN-205", description: "Body" });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("fusion://task/FN-205");
|
||||
});
|
||||
|
||||
it("Lifecycle: initialize() succeeds and sets initialized state", async () => {
|
||||
const manager = new ShareManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect((manager as any).initialized).toBe(true);
|
||||
});
|
||||
|
||||
it("Lifecycle: initialize() is idempotent (second call is no-op)", async () => {
|
||||
const manager = new ShareManager();
|
||||
|
||||
await manager.initialize();
|
||||
await expect(manager.initialize()).resolves.toBeUndefined();
|
||||
|
||||
expect((manager as any).initialized).toBe(true);
|
||||
});
|
||||
|
||||
it("Lifecycle: getDeepLinkBaseUrl() returns the configured URL", () => {
|
||||
const manager = new ShareManager({ deepLinkBaseUrl: "fusion://custom/" });
|
||||
|
||||
expect(manager.getDeepLinkBaseUrl()).toBe("fusion://custom/");
|
||||
});
|
||||
|
||||
it("Lifecycle: destroy() removes all listeners and resets initialized state", async () => {
|
||||
const manager = new ShareManager();
|
||||
const onSuccess = vi.fn();
|
||||
manager.on("share:success", onSuccess);
|
||||
|
||||
await manager.initialize();
|
||||
await manager.destroy();
|
||||
|
||||
expect((manager as any).initialized).toBe(false);
|
||||
|
||||
manager.emit("share:success", { taskId: "FN-206" });
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +1,104 @@
|
||||
import {
|
||||
DeepLinkManager,
|
||||
type DeepLinkManagerOptions,
|
||||
} from "./plugins/deep-links.js";
|
||||
import {
|
||||
PushNotificationManager,
|
||||
type PushNotificationManagerOptions,
|
||||
} from "./plugins/push-notifications.js";
|
||||
import { ShareManager, type ShareManagerOptions } from "./plugins/share.js";
|
||||
|
||||
export { DeepLinkManager } from "./plugins/deep-links.js";
|
||||
export type {
|
||||
DeepLinkEventMap,
|
||||
DeepLinkManagerOptions,
|
||||
DeepLinkPayload,
|
||||
} from "./plugins/deep-links.js";
|
||||
export { PushNotificationManager } from "./plugins/push-notifications.js";
|
||||
export type {
|
||||
PushNotificationEventMap,
|
||||
PushNotificationManagerOptions,
|
||||
} from "./plugins/push-notifications.js";
|
||||
export { ShareManager } from "./plugins/share.js";
|
||||
export type {
|
||||
ShareEventMap,
|
||||
ShareManagerOptions,
|
||||
ShareTaskPayload,
|
||||
} from "./plugins/share.js";
|
||||
export type { MobilePluginManager, PluginEventMap } from "./types.js";
|
||||
|
||||
interface LifecycleManager {
|
||||
initialize?: () => Promise<void>;
|
||||
start?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface InitializePluginsOptions {
|
||||
pushNotifications?:
|
||||
| boolean
|
||||
| PushNotificationManager
|
||||
| PushNotificationManagerOptions;
|
||||
share?: boolean | ShareManager | ShareManagerOptions;
|
||||
deepLinks?: boolean | DeepLinkManager | DeepLinkManagerOptions;
|
||||
}
|
||||
|
||||
export interface InitializePluginsResult {
|
||||
pushNotifications?: PushNotificationManager;
|
||||
share?: ShareManager;
|
||||
deepLinks?: DeepLinkManager;
|
||||
}
|
||||
|
||||
async function initializeManager(manager: LifecycleManager): Promise<void> {
|
||||
if (typeof manager.initialize === "function") {
|
||||
await manager.initialize();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof manager.start === "function") {
|
||||
await manager.start();
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializePlugins(
|
||||
options: InitializePluginsOptions = {},
|
||||
): Promise<InitializePluginsResult> {
|
||||
const result: InitializePluginsResult = {};
|
||||
const pushOptions = options.pushNotifications;
|
||||
|
||||
if (!pushOptions) {
|
||||
return result;
|
||||
const pushOptions = options.pushNotifications;
|
||||
if (pushOptions) {
|
||||
const pushNotifications =
|
||||
pushOptions instanceof PushNotificationManager
|
||||
? pushOptions
|
||||
: new PushNotificationManager(
|
||||
typeof pushOptions === "object" ? pushOptions : undefined,
|
||||
);
|
||||
|
||||
await initializeManager(pushNotifications);
|
||||
result.pushNotifications = pushNotifications;
|
||||
}
|
||||
|
||||
const pushNotifications =
|
||||
pushOptions instanceof PushNotificationManager
|
||||
? pushOptions
|
||||
: new PushNotificationManager(
|
||||
typeof pushOptions === "object" ? pushOptions : undefined,
|
||||
);
|
||||
const shareOptions = options.share;
|
||||
if (shareOptions) {
|
||||
const share =
|
||||
shareOptions instanceof ShareManager
|
||||
? shareOptions
|
||||
: new ShareManager(typeof shareOptions === "object" ? shareOptions : undefined);
|
||||
|
||||
await initializeManager(share);
|
||||
result.share = share;
|
||||
}
|
||||
|
||||
const deepLinkOptions = options.deepLinks;
|
||||
if (deepLinkOptions) {
|
||||
const deepLinks =
|
||||
deepLinkOptions instanceof DeepLinkManager
|
||||
? deepLinkOptions
|
||||
: new DeepLinkManager(
|
||||
typeof deepLinkOptions === "object" ? deepLinkOptions : undefined,
|
||||
);
|
||||
|
||||
await initializeManager(deepLinks);
|
||||
result.deepLinks = deepLinks;
|
||||
}
|
||||
|
||||
await pushNotifications.start();
|
||||
result.pushNotifications = pushNotifications;
|
||||
return result;
|
||||
}
|
||||
|
||||
273
packages/mobile/src/plugins/deep-links.ts
Normal file
273
packages/mobile/src/plugins/deep-links.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export interface DeepLinkPayload {
|
||||
url: string;
|
||||
taskId?: string;
|
||||
projectId?: string;
|
||||
target?: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DeepLinkManagerOptions {
|
||||
/** Custom URL scheme. Default: "fusion://" */
|
||||
scheme?: string;
|
||||
/** Universal link hosts to recognize. Default: [] */
|
||||
universalLinkHosts?: string[];
|
||||
}
|
||||
|
||||
export interface DeepLinkEventMap {
|
||||
"deeplink:received": DeepLinkPayload;
|
||||
"deeplink:error": { url: string; error: Error };
|
||||
}
|
||||
|
||||
type AppModule = typeof import("@capacitor/app");
|
||||
type AppListenerHandle = { remove: () => Promise<void> };
|
||||
|
||||
type HashChangeEventHandler = (event: HashChangeEvent) => void;
|
||||
|
||||
export class DeepLinkManager extends EventEmitter {
|
||||
private readonly scheme: string;
|
||||
private readonly universalLinkHosts: string[];
|
||||
private initialized = false;
|
||||
private appListenerHandle?: AppListenerHandle;
|
||||
private boundHashHandler?: HashChangeEventHandler;
|
||||
private appPlugin: AppModule["App"] | null = null;
|
||||
|
||||
constructor(options?: DeepLinkManagerOptions) {
|
||||
super();
|
||||
this.scheme = options?.scheme ?? "fusion://";
|
||||
this.universalLinkHosts = options?.universalLinkHosts ?? [];
|
||||
}
|
||||
|
||||
override on<K extends keyof DeepLinkEventMap>(
|
||||
event: K,
|
||||
listener: (payload: DeepLinkEventMap[K]) => void,
|
||||
): this;
|
||||
override on(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
override on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
override off<K extends keyof DeepLinkEventMap>(
|
||||
event: K,
|
||||
listener: (payload: DeepLinkEventMap[K]) => void,
|
||||
): this;
|
||||
override off(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
override off(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
|
||||
emit<K extends keyof DeepLinkEventMap>(event: K, payload: DeepLinkEventMap[K]): boolean;
|
||||
emit(event: string | symbol, payload?: unknown): boolean;
|
||||
emit(event: string | symbol, payload?: unknown): boolean {
|
||||
return super.emit(event, payload);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const app = await this.loadAppPlugin();
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appListenerHandle = await app.addListener("appUrlOpen", (event) => {
|
||||
this.handleUrl(event.url);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const win = globalThis.window;
|
||||
if (!win || typeof win.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.boundHashHandler = () => {
|
||||
const hash = win.location.hash ?? "";
|
||||
if (!hash.startsWith("#deeplink=")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedUrl = hash.slice("#deeplink=".length);
|
||||
if (!encodedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||
this.handleUrl(decodedUrl);
|
||||
} catch (error) {
|
||||
const normalizedError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Invalid deeplink hash payload");
|
||||
this.emit("deeplink:error", {
|
||||
url: encodedUrl,
|
||||
error: normalizedError,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
win.addEventListener("hashchange", this.boundHashHandler);
|
||||
}
|
||||
|
||||
handleUrl(url: string): DeepLinkPayload | null {
|
||||
try {
|
||||
const payload = this.parseUrl(url);
|
||||
this.emit("deeplink:received", payload);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this.emit("deeplink:error", {
|
||||
url,
|
||||
error: normalizedError,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getScheme(): string {
|
||||
return this.scheme;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
if (this.appListenerHandle) {
|
||||
try {
|
||||
await this.appListenerHandle.remove();
|
||||
} catch (error) {
|
||||
console.warn("Failed to remove appUrlOpen listener", error);
|
||||
}
|
||||
this.appListenerHandle = undefined;
|
||||
}
|
||||
|
||||
if (this.boundHashHandler && globalThis.window) {
|
||||
globalThis.window.removeEventListener("hashchange", this.boundHashHandler);
|
||||
this.boundHashHandler = undefined;
|
||||
}
|
||||
|
||||
this.initialized = false;
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
private parseUrl(url: string): DeepLinkPayload {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (this.isCustomSchemeUrl(url, parsedUrl)) {
|
||||
return this.parseCustomScheme(url, parsedUrl);
|
||||
}
|
||||
|
||||
if (this.isRecognizedUniversalLink(parsedUrl)) {
|
||||
return this.parseUniversalLink(url, parsedUrl);
|
||||
}
|
||||
|
||||
throw new Error("Unsupported deep link URL");
|
||||
}
|
||||
|
||||
private parseCustomScheme(url: string, parsedUrl: URL): DeepLinkPayload {
|
||||
const segments = [parsedUrl.hostname, ...parsedUrl.pathname.split("/").filter(Boolean)].filter(
|
||||
(segment) => segment.length > 0,
|
||||
);
|
||||
|
||||
const payload: DeepLinkPayload = { url };
|
||||
const [first, second, third, fourth] = segments;
|
||||
|
||||
if (first) {
|
||||
payload.target = first;
|
||||
}
|
||||
|
||||
if (first === "task" && second) {
|
||||
payload.taskId = second;
|
||||
}
|
||||
|
||||
if (first === "project" && second) {
|
||||
payload.projectId = second;
|
||||
if (third === "task" && fourth) {
|
||||
payload.taskId = fourth;
|
||||
}
|
||||
}
|
||||
|
||||
const params = this.collectParams(parsedUrl.searchParams);
|
||||
if (Object.keys(params).length > 0) {
|
||||
payload.params = params;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private parseUniversalLink(url: string, parsedUrl: URL): DeepLinkPayload {
|
||||
const payload: DeepLinkPayload = {
|
||||
url,
|
||||
taskId: parsedUrl.searchParams.get("task") ?? undefined,
|
||||
projectId: parsedUrl.searchParams.get("project") ?? undefined,
|
||||
target: parsedUrl.searchParams.get("target") ?? undefined,
|
||||
};
|
||||
|
||||
const params = this.collectParams(parsedUrl.searchParams, ["task", "project", "target"]);
|
||||
if (Object.keys(params).length > 0) {
|
||||
payload.params = params;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private collectParams(
|
||||
searchParams: URLSearchParams,
|
||||
exclusions: string[] = [],
|
||||
): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const exclusionSet = new Set(exclusions);
|
||||
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
if (exclusionSet.has(key)) {
|
||||
continue;
|
||||
}
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private isRecognizedUniversalLink(parsedUrl: URL): boolean {
|
||||
return (
|
||||
parsedUrl.protocol === "https:" &&
|
||||
this.universalLinkHosts.includes(parsedUrl.hostname)
|
||||
);
|
||||
}
|
||||
|
||||
private isCustomSchemeUrl(url: string, parsedUrl: URL): boolean {
|
||||
const configuredSchemeProtocol = this.normalizeSchemeProtocol(this.scheme);
|
||||
return parsedUrl.protocol === configuredSchemeProtocol || url.startsWith(this.scheme);
|
||||
}
|
||||
|
||||
private normalizeSchemeProtocol(scheme: string): string {
|
||||
if (scheme.endsWith("://")) {
|
||||
return `${scheme.slice(0, -3)}:`;
|
||||
}
|
||||
|
||||
if (scheme.endsWith(":")) {
|
||||
return scheme;
|
||||
}
|
||||
|
||||
return `${scheme}:`;
|
||||
}
|
||||
|
||||
private async loadAppPlugin(): Promise<AppModule["App"] | null> {
|
||||
if (this.appPlugin) {
|
||||
return this.appPlugin;
|
||||
}
|
||||
|
||||
try {
|
||||
const mod: AppModule = await import("@capacitor/app");
|
||||
this.appPlugin = mod.App;
|
||||
return this.appPlugin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
194
packages/mobile/src/plugins/share.ts
Normal file
194
packages/mobile/src/plugins/share.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export interface ShareTaskPayload {
|
||||
id: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ShareManagerOptions {
|
||||
/** Base URL for constructing deep links. Default: "fusion://task/" */
|
||||
deepLinkBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface ShareEventMap {
|
||||
"share:success": { taskId: string };
|
||||
"share:cancelled": { taskId: string };
|
||||
"share:error": { taskId: string; error: Error };
|
||||
}
|
||||
|
||||
type SharePlugin = typeof import("@capacitor/share");
|
||||
type NavigatorSharePayload = {
|
||||
title: string;
|
||||
text: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ShareCapacitorResult = {
|
||||
activityType?: string;
|
||||
};
|
||||
|
||||
export class ShareManager extends EventEmitter {
|
||||
private readonly deepLinkBaseUrl: string;
|
||||
private initialized = false;
|
||||
private sharePlugin: SharePlugin["Share"] | null = null;
|
||||
|
||||
constructor(options?: ShareManagerOptions) {
|
||||
super();
|
||||
this.deepLinkBaseUrl = options?.deepLinkBaseUrl ?? "fusion://task/";
|
||||
}
|
||||
|
||||
override on<K extends keyof ShareEventMap>(
|
||||
event: K,
|
||||
listener: (payload: ShareEventMap[K]) => void,
|
||||
): this;
|
||||
override on(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
override on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
override off<K extends keyof ShareEventMap>(
|
||||
event: K,
|
||||
listener: (payload: ShareEventMap[K]) => void,
|
||||
): this;
|
||||
override off(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
override off(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
|
||||
emit<K extends keyof ShareEventMap>(event: K, payload: ShareEventMap[K]): boolean;
|
||||
emit(event: string | symbol, payload?: unknown): boolean;
|
||||
emit(event: string | symbol, payload?: unknown): boolean {
|
||||
return super.emit(event, payload);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async shareTask(task: ShareTaskPayload): Promise<boolean> {
|
||||
const sharePayload = this.buildPayload(task);
|
||||
|
||||
try {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const share = await this.loadSharePlugin();
|
||||
if (!share) {
|
||||
throw new Error("Native share plugin is unavailable");
|
||||
}
|
||||
|
||||
const result = (await share.share(sharePayload)) as ShareCapacitorResult | undefined;
|
||||
if (result?.activityType === undefined) {
|
||||
this.emit("share:cancelled", { taskId: task.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
this.emit("share:success", { taskId: task.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
const navigatorShare = this.getNavigatorShare();
|
||||
if (navigatorShare) {
|
||||
try {
|
||||
await navigatorShare(sharePayload);
|
||||
this.emit("share:success", { taskId: task.id });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (this.isAbortError(error)) {
|
||||
this.emit("share:cancelled", { taskId: task.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const clipboardWriteText = this.getClipboardWriter();
|
||||
if (!clipboardWriteText) {
|
||||
throw new Error("No share mechanism available on this platform");
|
||||
}
|
||||
|
||||
await clipboardWriteText(sharePayload.url);
|
||||
this.emit("share:success", { taskId: task.id });
|
||||
return true;
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this.emit("share:error", { taskId: task.id, error: normalizedError });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getDeepLinkBaseUrl(): string {
|
||||
return this.deepLinkBaseUrl;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
private buildPayload(task: ShareTaskPayload): NavigatorSharePayload {
|
||||
const title = task.title ?? `Task ${task.id}`;
|
||||
const text =
|
||||
task.description.length > 200
|
||||
? `${task.description.slice(0, 200)}...`
|
||||
: task.description;
|
||||
const url = `${this.deepLinkBaseUrl}${task.id}`;
|
||||
|
||||
return { title, text, url };
|
||||
}
|
||||
|
||||
private async loadSharePlugin(): Promise<SharePlugin["Share"] | null> {
|
||||
if (this.sharePlugin) {
|
||||
return this.sharePlugin;
|
||||
}
|
||||
|
||||
try {
|
||||
const mod: SharePlugin = await import("@capacitor/share");
|
||||
this.sharePlugin = mod.Share;
|
||||
return this.sharePlugin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getNavigatorShare(): ((payload: NavigatorSharePayload) => Promise<void>) | null {
|
||||
const nav = globalThis.navigator as
|
||||
| (Navigator & { share?: (payload: NavigatorSharePayload) => Promise<void> })
|
||||
| undefined;
|
||||
|
||||
if (!nav || typeof nav.share !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nav.share.bind(nav);
|
||||
}
|
||||
|
||||
private getClipboardWriter(): ((value: string) => Promise<void>) | null {
|
||||
const nav = globalThis.navigator as
|
||||
| (Navigator & { clipboard?: { writeText?: (value: string) => Promise<void> } })
|
||||
| undefined;
|
||||
|
||||
const clipboard = nav?.clipboard;
|
||||
const writeText = clipboard?.writeText;
|
||||
if (typeof writeText !== "function" || !clipboard) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return writeText.bind(clipboard);
|
||||
}
|
||||
|
||||
private isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
(error instanceof DOMException && error.name === "AbortError") ||
|
||||
(typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
(error as { name?: string }).name === "AbortError")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user