atagen

malice

public
Star0
Watch0
31606c86aa9f9ed767d591ad237830e4df6a856c
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/push.tsTypescript166 lines6.0 KB
1
import { writable } from 'svelte/store';
2
import { runCommand } from './api';
3
import {
4
  DEVICE_PUSH_APP_ID,
5
  devicePusherRequest,
6
  parseStoredPush,
7
  stalePushers,
8
  subscriptionKeys,
9
  syncAction,
10
  urlBase64ToBytes,
11
  type PushSubscriptionKeys,
12
  type StoredPush
13
} from './push-model';
14
15
/** True while this browser holds a subscription the server knows about, so in-app notifications stand down. */
16
export const pushActive = writable(false);
17
18
const supported = typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window;
19
const storageKey = (account: string) => `malice.push.${account}`;
20
const browserKey = 'malice.push.browser';
21
const vapidPath = __MALICE_DEVICE__ ? '/push/vapid' : '/api/push/vapid';
22
23
let queue: Promise<unknown> = Promise.resolve();
24
25
/** Enable, disable and launch checks all touch one subscription, so they run one at a time. */
26
function serial<T>(task: () => Promise<T>): Promise<T> {
27
  const run = queue.then(task, task);
28
  queue = run.catch(() => undefined);
29
  return run;
30
}
31
32
function browserId(): string {
33
  try {
34
    const existing = localStorage.getItem(browserKey);
35
    if (existing) return existing;
36
    const id = crypto.randomUUID();
37
    localStorage.setItem(browserKey, id);
38
    return id;
39
  } catch {
40
    return 'unknown';
41
  }
42
}
43
44
function readStored(account: string): StoredPush | null {
45
  try {
46
    return parseStoredPush(localStorage.getItem(storageKey(account)));
47
  } catch {
48
    return null;
49
  }
50
}
51
52
function writeStored(account: string, stored: StoredPush | null): void {
53
  try {
54
    if (stored) localStorage.setItem(storageKey(account), JSON.stringify(stored));
55
    else localStorage.removeItem(storageKey(account));
56
  } catch {}
57
}
58
59
async function registration(): Promise<ServiceWorkerRegistration | null> {
60
  if (!supported) return null;
61
  return (await navigator.serviceWorker.getRegistration()) ?? null;
62
}
63
64
async function applicationServerKey(): Promise<Uint8Array<ArrayBuffer>> {
65
  const res = await fetch(vapidPath, { credentials: 'same-origin' });
66
  if (!res.ok) throw new Error('push key unavailable');
67
  const body: unknown = await res.json().catch(() => null);
68
  const key = typeof body === 'string' ? body : (body as { public_key?: unknown } | null)?.public_key;
69
  if (typeof key !== 'string') throw new Error('push key malformed');
70
  return urlBase64ToBytes(key);
71
}
72
73
async function post(path: string, body: unknown): Promise<void> {
74
  const res = await fetch(path, {
75
    method: 'POST',
76
    credentials: 'same-origin',
77
    headers: { 'content-type': 'application/json' },
78
    body: JSON.stringify(body)
79
  });
80
  if (!res.ok) throw new Error(`push request failed (${res.status})`);
81
}
82
83
async function removeRemote(stored: StoredPush): Promise<void> {
84
  if (__MALICE_DEVICE__) {
85
    if (stored.pushkey) await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey: stored.pushkey });
86
  } else {
87
    await post('/api/push/unsubscribe', { endpoint: stored.endpoint });
88
  }
89
}
90
91
async function register(account: string, keys: PushSubscriptionKeys): Promise<void> {
92
  const previous = readStored(account);
93
  if (__MALICE_DEVICE__) {
94
    await runCommand(devicePusherRequest(keys, window.location.origin, navigator.language, browserId()));
95
    if (previous?.pushkey && previous.pushkey !== keys.p256dh) {
96
      await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey: previous.pushkey }).catch(() => undefined);
97
    }
98
  } else {
99
    await post('/api/push/subscribe', { endpoint: keys.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } });
100
  }
101
  writeStored(account, { endpoint: keys.endpoint, pushkey: keys.p256dh });
102
}
103
104
async function dropStale(endpoint: string): Promise<void> {
105
  const reply = await runCommand({ op: 'pushers' });
106
  for (const pushkey of stalePushers(reply, browserId(), endpoint)) {
107
    await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey });
108
  }
109
}
110
111
async function subscribe(reg: ServiceWorkerRegistration): Promise<PushSubscription> {
112
  return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: await applicationServerKey() });
113
}
114
115
async function activate(account: string, sub: PushSubscription): Promise<void> {
116
  const keys = subscriptionKeys(sub);
117
  if (!keys) throw new Error('subscription has no keys');
118
  await register(account, keys);
119
}
120
121
/** Runs inside the settings toggle after permission is granted. */
122
export function enablePush(account: string): Promise<void> {
123
  return serial(async () => {
124
    try {
125
      const reg = await registration();
126
      if (!reg) return;
127
      await activate(account, (await reg.pushManager.getSubscription()) ?? (await subscribe(reg)));
128
      pushActive.set(true);
129
    } catch {
130
      pushActive.set(false);
131
    }
132
  });
133
}
134
135
/** Removes the server side registration and the browser subscription. Used by the toggle and before logout. */
136
export function disablePush(account: string): Promise<void> {
137
  return serial(async () => {
138
    pushActive.set(false);
139
    const stored = readStored(account);
140
    if (stored) await removeRemote(stored).catch(() => undefined);
141
    writeStored(account, null);
142
    const sub = await (await registration())?.pushManager.getSubscription();
143
    await sub?.unsubscribe().catch(() => false);
144
  });
145
}
146
147
/** Launch check. iOS revokes subscriptions after missed notifications and push services rotate endpoints. */
148
export function syncPush(account: string, wanted: boolean): Promise<void> {
149
  return serial(async () => {
150
    try {
151
      const reg = supported && Notification.permission === 'granted' ? await registration() : null;
152
      if (!reg) {
153
        pushActive.set(false);
154
        return;
155
      }
156
      let sub = await reg.pushManager.getSubscription();
157
      const action = syncAction(wanted, sub?.endpoint ?? null, readStored(account));
158
      if (action === 'subscribe') sub = await subscribe(reg);
159
      if (action !== 'none' && sub) await activate(account, sub);
160
      pushActive.set(wanted && sub !== null);
161
      if (__MALICE_DEVICE__ && wanted && sub) await dropStale(sub.endpoint).catch(() => undefined);
162
    } catch {
163
      pushActive.set(false);
164
    }
165
  });
166
}