diff --git a/.env.example b/.env.example index 3998101f..5d1f5c70 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,20 @@ DREAMSYNC_MAPPING_DB_PATH="/path/to/dreamsync/mapping/db" GROUP_CHARTER_MAPPING_DB_PATH=/path/to/charter/mapping/db CERBERUS_MAPPING_DB_PATH=/path/to/cerberus/mapping/db -GOOGLE_APPLICATION_CREDENTIALS="/path/to/firebase-secrets.json" +GOOGLE_APPLICATION_CREDENTIALS="/Users/sosweetham/projs/metastate/prototype/secrets/eid-w-firebase-adminsdk.json" + +# Notification Trigger (APNS/FCM toy platform) +NOTIFICATION_TRIGGER_PORT=3998 +# Full URL for control panel proxy (optional; defaults to http://localhost:NOTIFICATION_TRIGGER_PORT) +NOTIFICATION_TRIGGER_URL=http://localhost:3998 +# APNS (iOS) - from Apple Developer +APNS_KEY_PATH="/Users/sosweetham/projs/metastate/prototype/secrets/AuthKey_A3BBXD9YR3.p8" +APNS_KEY_ID="A3BBXD9YR3" +APNS_TEAM_ID="M49C8XS835" +APNS_BUNDLE_ID="com.example.app" +APNS_PRODUCTION=false +# Broadcast push (Live Activities) - base64 channel ID +APNS_BROADCAST_CHANNEL_ID=znbhuBJCEfEAAMIJbS9xUw== #PUBLIC_REGISTRY_URL="https://registry.w3ds.metastate.foundation" #PUBLIC_PROVISIONER_URL="https://provisioner.w3ds.metastate.foundation" diff --git a/infrastructure/control-panel/.env.example b/infrastructure/control-panel/.env.example index 2e5a24e1..6d28f480 100644 --- a/infrastructure/control-panel/.env.example +++ b/infrastructure/control-panel/.env.example @@ -5,3 +5,6 @@ LOKI_PASSWORD=admin # Registry Configuration PUBLIC_REGISTRY_URL=https://registry.staging.metastate.foundation + +# Notification Trigger (for Notifications tab proxy) +NOTIFICATION_TRIGGER_URL=http://localhost:3998 diff --git a/infrastructure/control-panel/src/lib/services/notificationService.ts b/infrastructure/control-panel/src/lib/services/notificationService.ts new file mode 100644 index 00000000..cfad78ab --- /dev/null +++ b/infrastructure/control-panel/src/lib/services/notificationService.ts @@ -0,0 +1,132 @@ +import { env } from '$env/dynamic/private'; + +export interface NotificationPayload { + title: string; + body: string; + subtitle?: string; + data?: Record; + sound?: string; + badge?: number; + clickAction?: string; +} + +export interface SendNotificationRequest { + token: string; + platform?: 'ios' | 'android'; + payload: NotificationPayload; +} + +export interface SendResult { + success: boolean; + error?: string; +} + +function getBaseUrl(): string { + const url = env.NOTIFICATION_TRIGGER_URL; + if (url) return url; + const port = env.NOTIFICATION_TRIGGER_PORT || '3998'; + return `http://localhost:${port}`; +} + +export async function sendNotification( + request: SendNotificationRequest +): Promise { + const baseUrl = getBaseUrl(); + try { + const response = await fetch(`${baseUrl}/api/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal: AbortSignal.timeout(15000) + }); + const data = await response.json(); + if (data.success) return { success: true }; + return { success: false, error: data.error ?? 'Unknown error' }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Request failed' + }; + } +} + +export async function getDevicesWithTokens(): Promise< + { token: string; platform: string; eName: string }[] +> { + const { env } = await import('$env/dynamic/private'); + const provisionerUrl = + env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001'; + try { + const response = await fetch(`${provisionerUrl}/api/devices/list`, { + signal: AbortSignal.timeout(10000) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + return data.devices ?? []; + } catch (err) { + console.error('Failed to fetch devices:', err); + return []; + } +} + +export async function getDevicesByEName(eName: string): Promise< + { token: string; platform: string; eName: string }[] +> { + const { env } = await import('$env/dynamic/private'); + const provisionerUrl = + env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001'; + try { + const response = await fetch( + `${provisionerUrl}/api/devices/by-ename/${encodeURIComponent(eName)}`, + { signal: AbortSignal.timeout(10000) } + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + return data.devices ?? []; + } catch (err) { + console.error('Failed to fetch devices by eName:', err); + return []; + } +} + +export async function sendBulkNotifications( + tokens: string[], + payload: NotificationPayload, + platform?: 'ios' | 'android' +): Promise<{ sent: number; failed: number; errors: { token: string; error: string }[] }> { + const results = await Promise.all( + tokens.map(async (token) => { + const result = await sendNotification({ + token: token.trim(), + platform, + payload + }); + return { token: token.trim(), ...result }; + }) + ); + + const sent = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success); + return { + sent, + failed: failed.length, + errors: failed.map((r) => ({ token: r.token.slice(0, 20) + '...', error: r.error ?? 'Unknown' })) + }; +} + +export async function checkNotificationTriggerHealth(): Promise<{ + ok: boolean; + apns: boolean; + fcm: boolean; +}> { + const baseUrl = getBaseUrl(); + try { + const response = await fetch(`${baseUrl}/api/health`, { + signal: AbortSignal.timeout(5000) + }); + const data = await response.json(); + return { ok: data.ok ?? false, apns: data.apns ?? false, fcm: data.fcm ?? false }; + } catch { + return { ok: false, apns: false, fcm: false }; + } +} diff --git a/infrastructure/control-panel/src/routes/+layout.svelte b/infrastructure/control-panel/src/routes/+layout.svelte index 812f6fd2..7cc102df 100644 --- a/infrastructure/control-panel/src/routes/+layout.svelte +++ b/infrastructure/control-panel/src/routes/+layout.svelte @@ -12,7 +12,8 @@ const navLinks = [ { label: 'Dashboard', href: '/' }, { label: 'Monitoring', href: '/monitoring' }, - { label: 'Actions', href: '/actions' } + { label: 'Actions', href: '/actions' }, + { label: 'Notifications', href: '/notifications' } ]; const isActive = (href: string) => (href === '/' ? pageUrl === '/' : pageUrl.startsWith(href)); diff --git a/infrastructure/control-panel/src/routes/api/notifications/devices-count/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/devices-count/+server.ts new file mode 100644 index 00000000..a50f85e3 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/devices-count/+server.ts @@ -0,0 +1,12 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { getDevicesWithTokens } from '$lib/services/notificationService'; + +export const GET: RequestHandler = async () => { + try { + const devices = await getDevicesWithTokens(); + return json({ count: devices.length }); + } catch { + return json({ count: 0 }); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/health/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/health/+server.ts new file mode 100644 index 00000000..df479263 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/health/+server.ts @@ -0,0 +1,12 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { checkNotificationTriggerHealth } from '$lib/services/notificationService'; + +export const GET: RequestHandler = async () => { + try { + const health = await checkNotificationTriggerHealth(); + return json(health); + } catch { + return json({ ok: false, apns: false, fcm: false }); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/send-bulk-all/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/send-bulk-all/+server.ts new file mode 100644 index 00000000..cb943970 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/send-bulk-all/+server.ts @@ -0,0 +1,61 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + getDevicesWithTokens, + sendBulkNotifications +} from '$lib/services/notificationService'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { payload } = body; + + if (!payload?.title || !payload?.body) { + return json( + { success: false, error: 'Missing payload.title or payload.body' }, + { status: 400 } + ); + } + + const devices = await getDevicesWithTokens(); + const tokens = devices.map((d) => d.token); + + if (tokens.length === 0) { + return json( + { + success: false, + error: 'No registered devices with push tokens found' + }, + { status: 400 } + ); + } + + const result = await sendBulkNotifications( + tokens, + { + title: String(payload.title), + body: String(payload.body), + subtitle: payload.subtitle ? String(payload.subtitle) : undefined, + data: payload.data, + sound: payload.sound ? String(payload.sound) : undefined, + badge: payload.badge !== undefined ? Number(payload.badge) : undefined, + clickAction: payload.clickAction ? String(payload.clickAction) : undefined + } + // platform auto-detected per token + ); + + return json({ + success: true, + sent: result.sent, + failed: result.failed, + total: tokens.length, + errors: result.errors + }); + } catch (err) { + console.error('Bulk-all send error:', err); + return json( + { success: false, error: err instanceof Error ? err.message : 'Internal error' }, + { status: 500 } + ); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/send-bulk/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/send-bulk/+server.ts new file mode 100644 index 00000000..530b17b5 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/send-bulk/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { sendBulkNotifications } from '$lib/services/notificationService'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { tokens, platform, payload } = body; + + if (!Array.isArray(tokens) || tokens.length === 0) { + return json({ success: false, error: 'tokens must be a non-empty array' }, { status: 400 }); + } + if (!payload?.title || !payload?.body) { + return json( + { success: false, error: 'Missing payload.title or payload.body' }, + { status: 400 } + ); + } + + const validTokens = tokens + .filter((t: unknown) => typeof t === 'string' && t.trim().length > 0) + .map((t: string) => t.trim()); + + if (validTokens.length === 0) { + return json({ success: false, error: 'No valid tokens' }, { status: 400 }); + } + + const result = await sendBulkNotifications( + validTokens, + { + title: String(payload.title), + body: String(payload.body), + subtitle: payload.subtitle ? String(payload.subtitle) : undefined, + data: payload.data, + sound: payload.sound ? String(payload.sound) : undefined, + badge: payload.badge !== undefined ? Number(payload.badge) : undefined, + clickAction: payload.clickAction ? String(payload.clickAction) : undefined + }, + platform && ['ios', 'android'].includes(platform) ? platform : undefined + ); + + return json({ + success: true, + sent: result.sent, + failed: result.failed, + errors: result.errors + }); + } catch (err) { + console.error('Bulk notification send error:', err); + return json( + { success: false, error: err instanceof Error ? err.message : 'Internal error' }, + { status: 500 } + ); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/send-by-ename/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/send-by-ename/+server.ts new file mode 100644 index 00000000..81e0d2d2 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/send-by-ename/+server.ts @@ -0,0 +1,63 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + getDevicesByEName, + sendBulkNotifications +} from '$lib/services/notificationService'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { eName, payload } = body; + + if (!eName || typeof eName !== 'string' || !eName.trim()) { + return json( + { success: false, error: 'Missing or invalid eName' }, + { status: 400 } + ); + } + if (!payload?.title || !payload?.body) { + return json( + { success: false, error: 'Missing payload.title or payload.body' }, + { status: 400 } + ); + } + + const devices = await getDevicesByEName(eName.trim()); + const tokens = devices.map((d) => d.token); + + if (tokens.length === 0) { + return json( + { + success: false, + error: `No devices with push tokens found for eName: ${eName}` + }, + { status: 400 } + ); + } + + const result = await sendBulkNotifications(tokens, { + title: String(payload.title), + body: String(payload.body), + subtitle: payload.subtitle ? String(payload.subtitle) : undefined, + data: payload.data, + sound: payload.sound ? String(payload.sound) : undefined, + badge: payload.badge !== undefined ? Number(payload.badge) : undefined, + clickAction: payload.clickAction ? String(payload.clickAction) : undefined + }); + + return json({ + success: true, + sent: result.sent, + failed: result.failed, + total: tokens.length, + errors: result.errors + }); + } catch (err) { + console.error('Send by eName error:', err); + return json( + { success: false, error: err instanceof Error ? err.message : 'Internal error' }, + { status: 500 } + ); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/send/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/send/+server.ts new file mode 100644 index 00000000..88dc6d65 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/send/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { sendNotification } from '$lib/services/notificationService'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { token, platform, payload } = body; + + if (!token || typeof token !== 'string') { + return json({ success: false, error: 'Missing or invalid token' }, { status: 400 }); + } + if (!payload?.title || !payload?.body) { + return json( + { success: false, error: 'Missing payload.title or payload.body' }, + { status: 400 } + ); + } + + const result = await sendNotification({ + token: token.trim(), + platform, + payload: { + title: String(payload.title), + body: String(payload.body), + subtitle: payload.subtitle ? String(payload.subtitle) : undefined, + data: payload.data, + sound: payload.sound ? String(payload.sound) : undefined, + badge: payload.badge !== undefined ? Number(payload.badge) : undefined, + clickAction: payload.clickAction ? String(payload.clickAction) : undefined + } + }); + + if (result.success) { + return json({ success: true, message: 'Notification sent' }); + } + return json({ success: false, error: result.error }, { status: 500 }); + } catch (err) { + console.error('Notification send error:', err); + return json( + { success: false, error: err instanceof Error ? err.message : 'Internal error' }, + { status: 500 } + ); + } +}; diff --git a/infrastructure/control-panel/src/routes/notifications/+page.svelte b/infrastructure/control-panel/src/routes/notifications/+page.svelte new file mode 100644 index 00000000..2e3c74c9 --- /dev/null +++ b/infrastructure/control-panel/src/routes/notifications/+page.svelte @@ -0,0 +1,406 @@ + + +
+
+

Notifications

+

+ Send push notifications via APNS (iOS) and FCM (Android). Platform is auto-detected from + token format when not specified. +

+ {#if health} +
+ Trigger: {health.ok ? 'Connected' : 'Not connected'} + {#if health.ok} + APNS: {health.apns ? '✓' : '✗'} + FCM: {health.fcm ? '✓' : '✗'} + {/if} +
+ {/if} +
+ + +
+

Send single notification

+
+
+ + + {#if platformHint} +

Detected: {platformHint}

+ {/if} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Send + +
+
+ + +
+

Send by eName

+

+ Send to all devices registered for a specific eName (e.g. @user-id). +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Send to eName + +
+
+ + +
+

Send to all registered devices

+

+ One-button push to every device with a registered push token (from provisioner). +

+ {#if deviceCount !== null} +

+ {deviceCount} device{deviceCount === 1 ? '' : 's'} with push token{deviceCount === 1 ? '' : 's'} +

+ {/if} +
+
+ + +
+
+ + +
+
+ + +
+ + Send to all + +
+
+
+ +{#if toast} +
+

{toast.message}

+
+{/if} diff --git a/infrastructure/eid-wallet/PUSH_NOTIFICATIONS_SETUP.md b/infrastructure/eid-wallet/PUSH_NOTIFICATIONS_SETUP.md new file mode 100644 index 00000000..a41638f8 --- /dev/null +++ b/infrastructure/eid-wallet/PUSH_NOTIFICATIONS_SETUP.md @@ -0,0 +1,38 @@ +# Push Notifications Setup (Android & iOS) + +The eid-wallet uses [tauri-plugin-notifications](https://github.com/Choochmeque/tauri-plugin-notifications) for push notifications on Android (FCM) and iOS (APNs). + +## Android (Firebase Cloud Messaging) + +1. **Create a Firebase project** at [Firebase Console](https://console.firebase.google.com/). + +2. **Add an Android app** to your Firebase project with package name `foundation.metastate.eid_wallet`. + +3. **Download `google-services.json`** from Firebase Console and place it in: + ``` + src-tauri/gen/android/app/google-services.json + ``` + +4. **Note:** The Google Services classpath and plugin have been added to the Android build. If you regenerate the `gen/` folder (e.g., after `tauri android init`), re-apply these changes to: + - `gen/android/build.gradle.kts`: Add `classpath("com.google.gms:google-services:4.4.2")` to buildscript dependencies + - `gen/android/app/build.gradle.kts`: Add `apply(plugin = "com.google.gms.google-services")` at the bottom + +## iOS (Apple Push Notification service) + +1. **Add Push Notifications capability** in Xcode: + - Open `src-tauri/gen/apple/eid-wallet.xcodeproj` in Xcode + - Select the iOS target → Signing & Capabilities + - Click "+ Capability" and add "Push Notifications" + +2. The `aps-environment` entitlement has been added to `eid-wallet_iOS.entitlements` for development. For production builds, update to `production`. + +3. **Test on a physical device** — the iOS simulator has limited push notification support. + +## Usage + +The `NotificationService` automatically: +- Requests permissions via `requestPermissions()` +- Registers for push via `registerForPushNotifications()` on Android/iOS when `registerDevice()` is called +- Sends the FCM/APNs token to your provisioner as `fcmToken` in the device registration payload + +Your backend can use this token to send push notifications through Firebase Admin SDK (Android) or your APNs provider (iOS). diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index 8458b745..5c2dcb8e 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -32,7 +32,7 @@ "@tauri-apps/plugin-barcode-scanner": "^2.4.2", "@tauri-apps/plugin-biometric": "^2.3.2", "@tauri-apps/plugin-deep-link": "^2.4.5", - "@tauri-apps/plugin-notification": "^2.3.3", + "@choochmeque/tauri-plugin-notifications-api": "^0.4.3", "@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-store": "^2.4.1", "@veriff/incontext-sdk": "^2.4.0", diff --git a/infrastructure/eid-wallet/src-tauri/Cargo.lock b/infrastructure/eid-wallet/src-tauri/Cargo.lock index 5b05fb6f..cdce2478 100644 --- a/infrastructure/eid-wallet/src-tauri/Cargo.lock +++ b/infrastructure/eid-wallet/src-tauri/Cargo.lock @@ -55,7 +55,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -456,6 +456,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.44" @@ -572,6 +583,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -844,7 +864,7 @@ dependencies = [ "tauri-plugin-biometric", "tauri-plugin-crypto-hw", "tauri-plugin-deep-link", - "tauri-plugin-notification", + "tauri-plugin-notifications", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-store", @@ -1291,6 +1311,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -2819,12 +2840,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", ] [[package]] @@ -2847,16 +2869,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.5.1" @@ -2877,12 +2889,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "rand_hc" @@ -3308,7 +3317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3459,6 +3468,53 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swift-bridge" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384ed39ea10f1cefabb197b7d8e67f0034b15a94ccbb1038b8e020da59bfb0be" +dependencies = [ + "once_cell", + "swift-bridge-build", + "swift-bridge-macro", + "tokio", +] + +[[package]] +name = "swift-bridge-build" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71b36df21e7f8a8b5eeb718d2e71f9cfc308477bfb705981cca705de9767dcb7" +dependencies = [ + "proc-macro2", + "swift-bridge-ir", + "syn 1.0.109", + "tempfile", +] + +[[package]] +name = "swift-bridge-ir" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73bd16155df50708b92306945656e57d62d321290a7db490f299f709fb31c83" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "swift-bridge-macro" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a13dc0dc875d85341dec5b5344a7d713f20eb5650b71086b27d09a6ece272f" +dependencies = [ + "proc-macro2", + "quote", + "swift-bridge-ir", + "syn 1.0.109", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -3774,17 +3830,19 @@ dependencies = [ ] [[package]] -name = "tauri-plugin-notification" -version = "2.3.3" +name = "tauri-plugin-notifications" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +checksum = "ec9491977d0a3a5903bec9ff1fa989233014f37b923008f4994062e1275586b6" dependencies = [ "log", "notify-rust", - "rand 0.9.2", + "rand 0.10.0", "serde", "serde_json", "serde_repr", + "swift-bridge", + "swift-bridge-build", "tauri", "tauri-plugin", "thiserror 2.0.18", diff --git a/infrastructure/eid-wallet/src-tauri/Cargo.toml b/infrastructure/eid-wallet/src-tauri/Cargo.toml index 8cf2140d..790282b1 100644 --- a/infrastructure/eid-wallet/src-tauri/Cargo.toml +++ b/infrastructure/eid-wallet/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-deep-link = "2" -tauri-plugin-notification = "2" +tauri-plugin-notifications = { version = "0.4", default-features = false, features = ["push-notifications", "notify-rust"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-store = "2.4.1" diff --git a/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json b/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json index 6af140f4..871c2ced 100644 --- a/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json +++ b/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json @@ -14,7 +14,7 @@ "barcode-scanner:allow-open-app-settings", "deep-link:default", "crypto-hw:default", - "notification:default", + "notifications:default", "process:default", "opener:allow-default-urls" ], diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore b/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore index 4ecec9c1..ae2ee920 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore @@ -3,4 +3,6 @@ /src/main/assets/tauri.conf.json /tauri.build.gradle.kts /proguard-tauri.pro -/tauri.properties \ No newline at end of file +/tauri.properties + +google-services.json \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts b/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts index 0fe8797c..25bfd372 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts @@ -85,4 +85,5 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") } -apply(from = "tauri.build.gradle.kts") \ No newline at end of file +apply(from = "tauri.build.gradle.kts") +apply(plugin = "com.google.gms.google-services") \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/build.gradle.kts b/infrastructure/eid-wallet/src-tauri/gen/android/build.gradle.kts index 607240bc..4ceeb98f 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/build.gradle.kts +++ b/infrastructure/eid-wallet/src-tauri/gen/android/build.gradle.kts @@ -6,6 +6,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:8.11.0") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + classpath("com.google.gms:google-services:4.4.2") } } @@ -19,4 +20,3 @@ allprojects { tasks.register("clean").configure { delete("build") } - diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj index 55e11ecd..3a4c3da0 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj @@ -389,7 +389,7 @@ CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 0.3.0.0; - DEVELOPMENT_TEAM = 7F2T2WK6DR; + DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; FRAMEWORK_SEARCH_PATHS = ( @@ -416,7 +416,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); MARKETING_VERSION = 0.3.0; - PRODUCT_BUNDLE_IDENTIFIER = com.kodski.eid-wallet; + PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -437,7 +437,7 @@ CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 0.3.0.0; - DEVELOPMENT_TEAM = 7F2T2WK6DR; + DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; FRAMEWORK_SEARCH_PATHS = ( @@ -464,7 +464,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); MARKETING_VERSION = 0.3.0; - PRODUCT_BUNDLE_IDENTIFIER = com.kodski.eid-wallet; + PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/eid-wallet_iOS.entitlements b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/eid-wallet_iOS.entitlements index 0c67376e..903def2a 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/eid-wallet_iOS.entitlements +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/eid-wallet_iOS.entitlements @@ -1,5 +1,8 @@ - + + aps-environment + development + diff --git a/infrastructure/eid-wallet/src-tauri/src/lib.rs b/infrastructure/eid-wallet/src-tauri/src/lib.rs index dbeba59a..51e3a07b 100644 --- a/infrastructure/eid-wallet/src-tauri/src/lib.rs +++ b/infrastructure/eid-wallet/src-tauri/src/lib.rs @@ -82,7 +82,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_notifications::init()) .setup(move |_app| { #[cfg(mobile)] { diff --git a/infrastructure/eid-wallet/src/lib/services/NotificationService.ts b/infrastructure/eid-wallet/src/lib/services/NotificationService.ts index 24fb22c2..b81d59dc 100644 --- a/infrastructure/eid-wallet/src/lib/services/NotificationService.ts +++ b/infrastructure/eid-wallet/src/lib/services/NotificationService.ts @@ -1,10 +1,11 @@ import { PUBLIC_PROVISIONER_URL } from "$env/static/public"; -import { invoke } from "@tauri-apps/api/core"; import { isPermissionGranted, + registerForPushNotifications, requestPermission, sendNotification, -} from "@tauri-apps/plugin-notification"; +} from "@choochmeque/tauri-plugin-notifications-api"; +import { invoke } from "@tauri-apps/api/core"; export interface DeviceRegistration { eName: string; @@ -341,18 +342,28 @@ class NotificationService { } /** - * Get FCM token for push notifications (mobile only) + * Get push notification token (FCM on Android, APNs on iOS) */ private async getFCMToken(): Promise { try { - // This would need to be implemented with Firebase SDK - // For now, return undefined as we're focusing on local notifications - return undefined; + return await registerForPushNotifications(); } catch (error) { - console.error("Failed to get FCM token:", error); + console.error("Failed to get push notification token:", error); return undefined; } } + + /** + * Request permissions and get push notification token (FCM on Android, APNs on iOS). + * Returns undefined on desktop or if permission is denied. + */ + async getPushToken(): Promise { + const hasPermission = await this.requestPermissions(); + if (!hasPermission) return undefined; + const platform = await this.getPlatform(); + if (platform !== "android" && platform !== "ios") return undefined; + return this.getFCMToken(); + } /** * Get eName from vault (helper method) */ diff --git a/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte b/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte index 1179735b..67b8bae7 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte @@ -32,6 +32,21 @@ onMount(async () => { console.log("User authenticated, allowing access to app routes"); + // Register device for push notifications (eName + token to provisioner) + try { + const notificationService = globalState.notificationService; + const ename = + vault && "ename" in vault ? String(vault.ename) : undefined; + if (ename) { + await notificationService.registerDevice(ename); + } + } catch (error) { + console.error( + "Failed to register device for notifications:", + error, + ); + } + // Check for notifications after successful authentication try { const notificationService = globalState.notificationService; diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index bb6fec2e..c4a4ba69 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -8,6 +8,7 @@ import { } from "$env/static/public"; import { Hero } from "$lib/fragments"; import { GlobalState } from "$lib/global"; +import NotificationService from "$lib/services/NotificationService"; import { pendingRecovery } from "$lib/stores/pendingRecovery"; import { ButtonAction } from "$lib/ui"; import { capitalize, getCanonicalBindingDocString } from "$lib/utils"; @@ -18,6 +19,9 @@ import { Shadow } from "svelte-loading-spinners"; import { v4 as uuidv4 } from "uuid"; import { provision } from "wallet-sdk"; +let pushToken = $state(undefined); +let pushTokenError = $state(undefined); +let pushTokenLoading = $state(true); const ANONYMOUS_VERIFICATION_CODE = "d66b7138-538a-465f-a6ce-f6985854c3f4"; const KEY_ID = "default"; @@ -730,7 +734,19 @@ const handleEnamePassphraseRecovery = async () => { } }; -onMount(() => { +onMount(async () => { + // Fetch push notification token for display (Android/iOS) + try { + pushToken = await NotificationService.getInstance().getPushToken(); + if (!pushToken) + pushTokenError = "No token (desktop or permission denied)"; + } catch (e) { + pushTokenError = + e instanceof Error ? e.message : "Failed to get push token"; + } finally { + pushTokenLoading = false; + } + // Detect upgrade mode from query param const url = new URL(window.location.href); if (url.searchParams.get("upgrade") === "1") { @@ -746,6 +762,16 @@ onMount(() => { class="min-h-svh px-[5vw] flex flex-col justify-between" style="padding-top: max(4svh, env(safe-area-inset-top)); padding-bottom: max(16px, env(safe-area-inset-bottom));" > +
+

Push token (FCM/APNs):

+ {#if pushTokenLoading} + Loading... + {:else if pushToken} + {pushToken} + {:else} + {pushTokenError ?? "—"} + {/if} +
{ + await queryRunner.query(` + CREATE TABLE "device_token" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "eName" character varying NOT NULL, + "token" character varying NOT NULL, + "platform" character varying NOT NULL, + "deviceId" character varying NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_device_token" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "UQ_device_token_ename_deviceid"`); + await queryRunner.query(`DROP INDEX "IDX_device_token_ename"`); + await queryRunner.query(`DROP TABLE "device_token"`); + } +} diff --git a/infrastructure/evault-core/src/services/DeviceTokenService.ts b/infrastructure/evault-core/src/services/DeviceTokenService.ts new file mode 100644 index 00000000..a7fe5f3f --- /dev/null +++ b/infrastructure/evault-core/src/services/DeviceTokenService.ts @@ -0,0 +1,94 @@ +import { Repository } from "typeorm"; +import { DeviceToken } from "../entities/DeviceToken"; + +export interface DeviceTokenRegistration { + eName: string; + deviceId: string; + platform: string; + token: string; +} + +export class DeviceTokenService { + constructor(private deviceTokenRepository: Repository) {} + + async register(registration: DeviceTokenRegistration): Promise { + const { eName, deviceId, platform, token } = registration; + + // 1. Exact match: same eName + deviceId → update token/platform + const byEnameAndDevice = await this.deviceTokenRepository.findOne({ + where: { eName, deviceId }, + }); + if (byEnameAndDevice) { + byEnameAndDevice.token = token; + byEnameAndDevice.platform = platform; + byEnameAndDevice.updatedAt = new Date(); + return this.deviceTokenRepository.save(byEnameAndDevice); + } + + // 2. Same token (same physical device) but different eName or deviceId → update eName/deviceId + const byToken = await this.deviceTokenRepository.findOne({ + where: { token }, + }); + if (byToken) { + byToken.eName = eName; + byToken.deviceId = deviceId; + byToken.platform = platform; + byToken.updatedAt = new Date(); + return this.deviceTokenRepository.save(byToken); + } + + // 3. Both eName and token are new → create + const deviceToken = this.deviceTokenRepository.create({ + eName, + deviceId, + platform, + token, + }); + return this.deviceTokenRepository.save(deviceToken); + } + + async getDevicesWithTokens(): Promise< + { token: string; platform: string; eName: string }[] + > { + const tokens = await this.deviceTokenRepository.find({ + order: { updatedAt: "DESC" }, + }); + return tokens.map((t) => ({ + token: t.token, + platform: t.platform, + eName: t.eName, + })); + } + + async getDevicesByEName(eName: string): Promise< + { token: string; platform: string; eName: string }[] + > { + const normalized = eName.startsWith("@") ? eName : `@${eName}`; + const withoutAt = eName.replace(/^@/, ""); + const tokens = await this.deviceTokenRepository + .createQueryBuilder("dt") + .where("dt.eName = :e1 OR dt.eName = :e2", { + e1: normalized, + e2: withoutAt, + }) + .orderBy("dt.updatedAt", "DESC") + .getMany(); + return tokens.map((t) => ({ + token: t.token, + platform: t.platform, + eName: t.eName, + })); + } + + async getDeviceCount(): Promise { + return this.deviceTokenRepository.count(); + } + + async unregister(eName: string, deviceId: string): Promise { + const result = await this.deviceTokenRepository.delete({ + eName, + deviceId, + }); + return (result.affected ?? 0) > 0; + } +} diff --git a/infrastructure/evault-core/src/services/NotificationService.ts b/infrastructure/evault-core/src/services/NotificationService.ts index 2b0ede80..1eaf325f 100644 --- a/infrastructure/evault-core/src/services/NotificationService.ts +++ b/infrastructure/evault-core/src/services/NotificationService.ts @@ -124,6 +124,11 @@ export class NotificationService { }); } + async getDevicesWithPushTokens(): Promise { + const all = await this.getAllDevices(); + return all.filter((v) => v.fcmToken && v.fcmToken.trim().length > 0); + } + async getDeviceStats(): Promise<{ totalDevices: number; devicesByPlatform: Record }> { const verifications = await this.getAllDevices(); const devicesByPlatform: Record = {}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53275787..07de7d4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: '@auvo/tauri-plugin-crypto-hw-api': specifier: ^0.1.0 version: 0.1.0 + '@choochmeque/tauri-plugin-notifications-api': + specifier: ^0.4.3 + version: 0.4.3 '@didit-protocol/sdk-web': specifier: ^0.1.6 version: 0.1.8 @@ -297,9 +300,6 @@ importers: '@tauri-apps/plugin-deep-link': specifier: ^2.4.5 version: 2.4.7 - '@tauri-apps/plugin-notification': - specifier: ^2.3.3 - version: 2.3.3 '@tauri-apps/plugin-opener': specifier: ^2.5.2 version: 2.5.3 @@ -4558,6 +4558,9 @@ packages: '@chevrotain/utils@11.1.2': resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + '@choochmeque/tauri-plugin-notifications-api@0.4.3': + resolution: {integrity: sha512-nq8dL7z9ZFC+G9cWmy/FTkjURIYDwvg7PklaIQQLwwOZmPEfBfLyvJsHNE+xTXRZYWdP79nkVchxjwxja35fUw==} + '@chromatic-com/storybook@3.2.7': resolution: {integrity: sha512-fCGhk4cd3VA8RNg55MZL5CScdHqljsQcL9g6Ss7YuobHpSo9yytEWNdgMd5QxAHSPBlLGFHjnSmliM3G/BeBqw==} engines: {node: '>=16.0.0', yarn: '>=1.22.18'} @@ -9108,9 +9111,6 @@ packages: '@tauri-apps/plugin-deep-link@2.4.7': resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==} - '@tauri-apps/plugin-notification@2.3.3': - resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} - '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} @@ -20491,6 +20491,10 @@ snapshots: '@chevrotain/utils@11.1.2': {} + '@choochmeque/tauri-plugin-notifications-api@0.4.3': + dependencies: + '@tauri-apps/api': 2.10.1 + '@chromatic-com/storybook@3.2.7(react@18.3.1)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))': dependencies: chromatic: 11.29.0 @@ -26518,10 +26522,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-notification@2.3.3': - dependencies: - '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-opener@2.5.3': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c7b2afb..4f615292 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - platforms/*/* - infrastructure/* - services/* + - notification-trigger - tests/ - docs/ onlyBuiltDependencies: