From ba298a0ba6dbe900a5d84a3b7ed6d332df7ed333 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:02:37 +0200 Subject: [PATCH] ST-cmovs7ouz: lib/push-client.ts client-side push helpers + stub actions/push.ts Client-side helpers: isPushSupported, isIOSSafari, isStandalonePWA, urlBase64ToUint8Array, subscribeToPush, unsubscribeFromPush. Stub actions/push.ts zodat imports resolven (implementatie volgt in volgende taak). Unit tests voor urlBase64ToUint8Array. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/lib/push-client.test.ts | 35 ++++++++++++++++++++++ actions/push.ts | 11 +++++++ lib/push-client.ts | 50 +++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 __tests__/lib/push-client.test.ts create mode 100644 actions/push.ts create mode 100644 lib/push-client.ts diff --git a/__tests__/lib/push-client.test.ts b/__tests__/lib/push-client.test.ts new file mode 100644 index 0000000..761b6e1 --- /dev/null +++ b/__tests__/lib/push-client.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@/actions/push', () => ({ + subscribeToPushAction: vi.fn(), + unsubscribeFromPushAction: vi.fn(), +})) + +import { urlBase64ToUint8Array } from '@/lib/push-client' + +describe('urlBase64ToUint8Array', () => { + it('converts a base64url-encoded VAPID public key to Uint8Array', () => { + // 65-byte uncompressed EC public key encoded as base64url (no padding) + const base64url = 'BNMxB-LJm6XvGGiJSsYLdumcYiM7q9s_1aM9i5lI8lVzZ7GYJw1QkQFmrknwFsI4dI-e1iyvUhYHjNpHJKJD3oc' + const result = urlBase64ToUint8Array(base64url) + expect(result).toBeInstanceOf(Uint8Array) + expect(result.length).toBe(65) + expect(result[0]).toBe(0x04) // uncompressed EC point prefix + }) + + it('handles base64url with padding', () => { + // simple known vector: "hello" = aGVsbG8= in base64 + const result = urlBase64ToUint8Array('aGVsbG8') + expect(result).toBeInstanceOf(Uint8Array) + expect(Array.from(result)).toEqual([104, 101, 108, 108, 111]) // "hello" + }) + + it('converts - and _ characters correctly', () => { + // base64url uses - and _ instead of + and / + const base64standard = 'AB+/AA==' + const base64url = 'AB-_AA' + const fromStd = urlBase64ToUint8Array(base64standard) + const fromUrl = urlBase64ToUint8Array(base64url) + expect(Array.from(fromStd)).toEqual(Array.from(fromUrl)) + }) +}) diff --git a/actions/push.ts b/actions/push.ts new file mode 100644 index 0000000..318f7dc --- /dev/null +++ b/actions/push.ts @@ -0,0 +1,11 @@ +'use server' + +// Stub — full implementation added by ST-cmovs7t590009 (subscribeToPushAction + unsubscribeFromPushAction) + +export async function subscribeToPushAction(_sub: unknown): Promise { + throw new Error('Not implemented') +} + +export async function unsubscribeFromPushAction(_args: { endpoint: string }): Promise { + throw new Error('Not implemented') +} diff --git a/lib/push-client.ts b/lib/push-client.ts new file mode 100644 index 0000000..2889c7d --- /dev/null +++ b/lib/push-client.ts @@ -0,0 +1,50 @@ +import { subscribeToPushAction, unsubscribeFromPushAction } from '@/actions/push' + +export function isPushSupported(): boolean { + return typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window +} + +export function isIOSSafari(): boolean { + if (typeof window === 'undefined') return false + const ua = navigator.userAgent + return /iPhone|iPad/.test(ua) && !/CriOS|FxiOS/.test(ua) +} + +export function isStandalonePWA(): boolean { + if (typeof window === 'undefined') return false + return ( + window.matchMedia('(display-mode: standalone)').matches || + !!(navigator as Navigator & { standalone?: boolean }).standalone + ) +} + +export function urlBase64ToUint8Array(base64: string): Uint8Array { + const padding = '='.repeat((4 - (base64.length % 4)) % 4) + const base64Std = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/') + const rawData = atob(base64Std) + const buf = new Uint8Array(rawData.length) + for (let i = 0; i < rawData.length; i++) buf[i] = rawData.charCodeAt(i) + return buf +} + +export async function subscribeToPush(publicKey: string): Promise { + const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }) + await navigator.serviceWorker.ready + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }) + await subscribeToPushAction(sub.toJSON() as Parameters[0]) + return sub +} + +export async function unsubscribeFromPush(): Promise { + const reg = await navigator.serviceWorker.getRegistration() + const sub = await reg?.pushManager.getSubscription() + if (sub) { + await sub.unsubscribe() + await unsubscribeFromPushAction({ endpoint: sub.endpoint }) + } +}