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:
gsxdsm
2026-04-08 00:52:24 -07:00
parent b280bc711e
commit 0c55b9edfc
9 changed files with 1219 additions and 12 deletions

View File

@@ -13,6 +13,8 @@ const config: CapacitorConfig = {
// to the backend at the configured server url. // to the backend at the configured server url.
url: process.env.FUSION_BACKEND_URL || undefined, url: process.env.FUSION_BACKEND_URL || undefined,
cleartext: true, // Allow HTTP connections to local dev servers cleartext: true, // Allow HTTP connections to local dev servers
iosScheme: "fusion",
androidScheme: "fusion",
}, },
plugins: { plugins: {
SplashScreen: { SplashScreen: {

View File

@@ -59,3 +59,103 @@ Use `manager.getDeviceToken()` after registration to retrieve the native device
This package currently handles **receiving** push notifications and in-app routing events only. 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. Server-side FCM/APNs delivery infrastructure (token storage, provider credentials, push sending services) is intentionally out of scope for this feature.
## Native Sharing & Deep Links
### ShareManager
`ShareManager` opens platform-native sharing when available and always includes a Fusion deep link in the shared payload.
```ts
import { ShareManager } from "@fusion/mobile";
const manager = new ShareManager();
await manager.initialize();
await manager.shareTask({
id: "FN-1118",
title: "Mobile Plugins - Native Sharing & Deep Links",
description: "Implements native share sheet support and deep link parsing.",
});
```
#### Share behavior + fallbacks
- Builds a payload with:
- `title`: `task.title` or fallback `Task {id}`
- `text`: task description (truncated to 200 chars with `...` when needed)
- `url`: `${deepLinkBaseUrl}{task.id}` (default base: `fusion://task/`)
- **Native (Capacitor)**: uses `@capacitor/share`
- **Web fallback**: uses `navigator.share(...)` when available
- **Final fallback**: copies the deep-link URL to `navigator.clipboard.writeText(...)`
#### Share events
- `share:success``{ taskId }`
- `share:cancelled``{ taskId }`
- `share:error``{ taskId, error }`
### DeepLinkManager
`DeepLinkManager` handles incoming links and emits parsed payloads for app-level navigation.
```ts
import { DeepLinkManager } from "@fusion/mobile";
const deepLinks = new DeepLinkManager({
scheme: "fusion://",
universalLinkHosts: ["app.fusion.dev"],
});
await deepLinks.initialize();
deepLinks.on("deeplink:received", (payload) => {
// route to screen/task/project in app UI
console.log(payload);
});
```
#### Supported URL patterns
- `fusion://task/{taskId}`
- `fusion://project/{projectId}`
- `fusion://project/{projectId}/task/{taskId}`
- `fusion://settings`
- `fusion://agents`
- Query params are preserved in `payload.params` for custom-scheme links
Universal links are supported when the host is allowed in `universalLinkHosts`, e.g.:
- `https://app.fusion.dev/?task=FN-123`
- `https://app.fusion.dev/?project=my-project&task=FN-123&target=task`
#### Deep link events
- `deeplink:received` → parsed `DeepLinkPayload`
- `deeplink:error``{ url, error }`
Use `handleUrl(url)` for programmatic handling (for example, push-notification tap flows that already provide a URL string).
### Integration flow: share -> open -> navigate
A common flow is:
1. Use `ShareManager.shareTask(...)` to share a task link like `fusion://task/FN-123`
2. Recipient opens that link on mobile
3. `DeepLinkManager` receives/parses the URL
4. Your UI listens to `deeplink:received` and navigates to the matching task view
### Capacitor deep-link scheme registration
The Fusion mobile app registers the custom URL scheme in `packages/dashboard/capacitor.config.ts`:
- `server.iosScheme = "fusion"`
- `server.androidScheme = "fusion"`
### Browser hash listener (development/testing)
On non-native platforms, `DeepLinkManager` listens for hash changes in the form:
- `#deeplink=<encoded-url>`
This hash-based behavior is intended for development/testing only and is not a production universal-link replacement.

View File

@@ -12,8 +12,10 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@capacitor/app": "^7.1.2",
"@capacitor/core": "^7.0.0", "@capacitor/core": "^7.0.0",
"@capacitor/push-notifications": "^7.0.0" "@capacitor/push-notifications": "^7.0.0",
"@capacitor/share": "^7.0.4"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/android": "^7.0.0", "@capacitor/android": "^7.0.0",

View 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();
});
});

View 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();
});
});

View File

@@ -1,44 +1,104 @@
import {
DeepLinkManager,
type DeepLinkManagerOptions,
} from "./plugins/deep-links.js";
import { import {
PushNotificationManager, PushNotificationManager,
type PushNotificationManagerOptions, type PushNotificationManagerOptions,
} from "./plugins/push-notifications.js"; } 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 { PushNotificationManager } from "./plugins/push-notifications.js";
export type { export type {
PushNotificationEventMap, PushNotificationEventMap,
PushNotificationManagerOptions, PushNotificationManagerOptions,
} from "./plugins/push-notifications.js"; } 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"; export type { MobilePluginManager, PluginEventMap } from "./types.js";
interface LifecycleManager {
initialize?: () => Promise<void>;
start?: () => Promise<void>;
}
export interface InitializePluginsOptions { export interface InitializePluginsOptions {
pushNotifications?: pushNotifications?:
| boolean | boolean
| PushNotificationManager | PushNotificationManager
| PushNotificationManagerOptions; | PushNotificationManagerOptions;
share?: boolean | ShareManager | ShareManagerOptions;
deepLinks?: boolean | DeepLinkManager | DeepLinkManagerOptions;
} }
export interface InitializePluginsResult { export interface InitializePluginsResult {
pushNotifications?: PushNotificationManager; 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( export async function initializePlugins(
options: InitializePluginsOptions = {}, options: InitializePluginsOptions = {},
): Promise<InitializePluginsResult> { ): Promise<InitializePluginsResult> {
const result: InitializePluginsResult = {}; const result: InitializePluginsResult = {};
const pushOptions = options.pushNotifications;
if (!pushOptions) { const pushOptions = options.pushNotifications;
return result; if (pushOptions) {
const pushNotifications =
pushOptions instanceof PushNotificationManager
? pushOptions
: new PushNotificationManager(
typeof pushOptions === "object" ? pushOptions : undefined,
);
await initializeManager(pushNotifications);
result.pushNotifications = pushNotifications;
} }
const pushNotifications = const shareOptions = options.share;
pushOptions instanceof PushNotificationManager if (shareOptions) {
? pushOptions const share =
: new PushNotificationManager( shareOptions instanceof ShareManager
typeof pushOptions === "object" ? pushOptions : undefined, ? 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; return result;
} }

View 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;
}
}
}

View 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")
);
}
}

6
pnpm-lock.yaml generated
View File

@@ -334,12 +334,18 @@ importers:
packages/mobile: packages/mobile:
dependencies: dependencies:
'@capacitor/app':
specifier: ^7.1.2
version: 7.1.2(@capacitor/core@7.6.1)
'@capacitor/core': '@capacitor/core':
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.6.1 version: 7.6.1
'@capacitor/push-notifications': '@capacitor/push-notifications':
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.6(@capacitor/core@7.6.1) version: 7.0.6(@capacitor/core@7.6.1)
'@capacitor/share':
specifier: ^7.0.4
version: 7.0.4(@capacitor/core@7.6.1)
devDependencies: devDependencies:
'@capacitor/android': '@capacitor/android':
specifier: ^7.0.0 specifier: ^7.0.0