atagen

malice

public
Star0
Watch0
56f87be039353bc482f578c3a38441bd55ca13ee
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/push.ts

235ce394f90datagen2026-10-26 06:54 UTC1
import { writable } from 'svelte/store';
235ce394f90datagen2026-10-26 06:54 UTC2
import { runCommand } from './api';
235ce394f90datagen2026-10-26 06:54 UTC3
import {
235ce394f90datagen2026-10-26 06:54 UTC4
  DEVICE_PUSH_APP_ID,
235ce394f90datagen2026-10-26 06:54 UTC5
  devicePusherRequest,
235ce394f90datagen2026-10-26 06:54 UTC6
  parseStoredPush,
235ce394f90datagen2026-10-26 06:54 UTC7
  stalePushers,
235ce394f90datagen2026-10-26 06:54 UTC8
  subscriptionKeys,
235ce394f90datagen2026-10-26 06:54 UTC9
  syncAction,
235ce394f90datagen2026-10-26 06:54 UTC10
  urlBase64ToBytes,
235ce394f90datagen2026-10-26 06:54 UTC11
  type PushSubscriptionKeys,
235ce394f90datagen2026-10-26 06:54 UTC12
  type StoredPush
235ce394f90datagen2026-10-26 06:54 UTC13
} from './push-model';
235ce394f90datagen2026-10-26 06:54 UTC14
235ce394f90datagen2026-10-26 06:54 UTC15
/** True while this browser holds a subscription the server knows about, so in-app notifications stand down. */
235ce394f90datagen2026-10-26 06:54 UTC16
export const pushActive = writable(false);
235ce394f90datagen2026-10-26 06:54 UTC17
235ce394f90datagen2026-10-26 06:54 UTC18
const supported = typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window;
235ce394f90datagen2026-10-26 06:54 UTC19
const storageKey = (account: string) => `malice.push.${account}`;
235ce394f90datagen2026-10-26 06:54 UTC20
const browserKey = 'malice.push.browser';
235ce394f90datagen2026-10-26 06:54 UTC21
const vapidPath = __MALICE_DEVICE__ ? '/push/vapid' : '/api/push/vapid';
235ce394f90datagen2026-10-26 06:54 UTC22
235ce394f90datagen2026-10-26 06:54 UTC23
let queue: Promise<unknown> = Promise.resolve();
235ce394f90datagen2026-10-26 06:54 UTC24
235ce394f90datagen2026-10-26 06:54 UTC25
/** Enable, disable and launch checks all touch one subscription, so they run one at a time. */
235ce394f90datagen2026-10-26 06:54 UTC26
function serial<T>(task: () => Promise<T>): Promise<T> {
235ce394f90datagen2026-10-26 06:54 UTC27
  const run = queue.then(task, task);
235ce394f90datagen2026-10-26 06:54 UTC28
  queue = run.catch(() => undefined);
235ce394f90datagen2026-10-26 06:54 UTC29
  return run;
235ce394f90datagen2026-10-26 06:54 UTC30
}
235ce394f90datagen2026-10-26 06:54 UTC31
235ce394f90datagen2026-10-26 06:54 UTC32
function browserId(): string {
235ce394f90datagen2026-10-26 06:54 UTC33
  try {
235ce394f90datagen2026-10-26 06:54 UTC34
    const existing = localStorage.getItem(browserKey);
235ce394f90datagen2026-10-26 06:54 UTC35
    if (existing) return existing;
235ce394f90datagen2026-10-26 06:54 UTC36
    const id = crypto.randomUUID();
235ce394f90datagen2026-10-26 06:54 UTC37
    localStorage.setItem(browserKey, id);
235ce394f90datagen2026-10-26 06:54 UTC38
    return id;
235ce394f90datagen2026-10-26 06:54 UTC39
  } catch {
235ce394f90datagen2026-10-26 06:54 UTC40
    return 'unknown';
235ce394f90datagen2026-10-26 06:54 UTC41
  }
235ce394f90datagen2026-10-26 06:54 UTC42
}
235ce394f90datagen2026-10-26 06:54 UTC43
235ce394f90datagen2026-10-26 06:54 UTC44
function readStored(account: string): StoredPush | null {
235ce394f90datagen2026-10-26 06:54 UTC45
  try {
235ce394f90datagen2026-10-26 06:54 UTC46
    return parseStoredPush(localStorage.getItem(storageKey(account)));
235ce394f90datagen2026-10-26 06:54 UTC47
  } catch {
235ce394f90datagen2026-10-26 06:54 UTC48
    return null;
235ce394f90datagen2026-10-26 06:54 UTC49
  }
235ce394f90datagen2026-10-26 06:54 UTC50
}
235ce394f90datagen2026-10-26 06:54 UTC51
235ce394f90datagen2026-10-26 06:54 UTC52
function writeStored(account: string, stored: StoredPush | null): void {
235ce394f90datagen2026-10-26 06:54 UTC53
  try {
235ce394f90datagen2026-10-26 06:54 UTC54
    if (stored) localStorage.setItem(storageKey(account), JSON.stringify(stored));
235ce394f90datagen2026-10-26 06:54 UTC55
    else localStorage.removeItem(storageKey(account));
235ce394f90datagen2026-10-26 06:54 UTC56
  } catch {}
235ce394f90datagen2026-10-26 06:54 UTC57
}
235ce394f90datagen2026-10-26 06:54 UTC58
235ce394f90datagen2026-10-26 06:54 UTC59
async function registration(): Promise<ServiceWorkerRegistration | null> {
235ce394f90datagen2026-10-26 06:54 UTC60
  if (!supported) return null;
235ce394f90datagen2026-10-26 06:54 UTC61
  return (await navigator.serviceWorker.getRegistration()) ?? null;
235ce394f90datagen2026-10-26 06:54 UTC62
}
235ce394f90datagen2026-10-26 06:54 UTC63
235ce394f90datagen2026-10-26 06:54 UTC64
async function applicationServerKey(): Promise<Uint8Array<ArrayBuffer>> {
235ce394f90datagen2026-10-26 06:54 UTC65
  const res = await fetch(vapidPath, { credentials: 'same-origin' });
235ce394f90datagen2026-10-26 06:54 UTC66
  if (!res.ok) throw new Error('push key unavailable');
235ce394f90datagen2026-10-26 06:54 UTC67
  const body: unknown = await res.json().catch(() => null);
235ce394f90datagen2026-10-26 06:54 UTC68
  const key = typeof body === 'string' ? body : (body as { public_key?: unknown } | null)?.public_key;
235ce394f90datagen2026-10-26 06:54 UTC69
  if (typeof key !== 'string') throw new Error('push key malformed');
235ce394f90datagen2026-10-26 06:54 UTC70
  return urlBase64ToBytes(key);
235ce394f90datagen2026-10-26 06:54 UTC71
}
235ce394f90datagen2026-10-26 06:54 UTC72
235ce394f90datagen2026-10-26 06:54 UTC73
async function post(path: string, body: unknown): Promise<void> {
235ce394f90datagen2026-10-26 06:54 UTC74
  const res = await fetch(path, {
235ce394f90datagen2026-10-26 06:54 UTC75
    method: 'POST',
235ce394f90datagen2026-10-26 06:54 UTC76
    credentials: 'same-origin',
235ce394f90datagen2026-10-26 06:54 UTC77
    headers: { 'content-type': 'application/json' },
235ce394f90datagen2026-10-26 06:54 UTC78
    body: JSON.stringify(body)
235ce394f90datagen2026-10-26 06:54 UTC79
  });
235ce394f90datagen2026-10-26 06:54 UTC80
  if (!res.ok) throw new Error(`push request failed (${res.status})`);
235ce394f90datagen2026-10-26 06:54 UTC81
}
235ce394f90datagen2026-10-26 06:54 UTC82
235ce394f90datagen2026-10-26 06:54 UTC83
async function removeRemote(stored: StoredPush): Promise<void> {
235ce394f90datagen2026-10-26 06:54 UTC84
  if (__MALICE_DEVICE__) {
235ce394f90datagen2026-10-26 06:54 UTC85
    if (stored.pushkey) await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey: stored.pushkey });
235ce394f90datagen2026-10-26 06:54 UTC86
  } else {
235ce394f90datagen2026-10-26 06:54 UTC87
    await post('/api/push/unsubscribe', { endpoint: stored.endpoint });
235ce394f90datagen2026-10-26 06:54 UTC88
  }
235ce394f90datagen2026-10-26 06:54 UTC89
}
235ce394f90datagen2026-10-26 06:54 UTC90
235ce394f90datagen2026-10-26 06:54 UTC91
async function register(account: string, keys: PushSubscriptionKeys): Promise<void> {
235ce394f90datagen2026-10-26 06:54 UTC92
  const previous = readStored(account);
235ce394f90datagen2026-10-26 06:54 UTC93
  if (__MALICE_DEVICE__) {
235ce394f90datagen2026-10-26 06:54 UTC94
    await runCommand(devicePusherRequest(keys, window.location.origin, navigator.language, browserId()));
235ce394f90datagen2026-10-26 06:54 UTC95
    if (previous?.pushkey && previous.pushkey !== keys.p256dh) {
235ce394f90datagen2026-10-26 06:54 UTC96
      await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey: previous.pushkey }).catch(() => undefined);
235ce394f90datagen2026-10-26 06:54 UTC97
    }
235ce394f90datagen2026-10-26 06:54 UTC98
  } else {
235ce394f90datagen2026-10-26 06:54 UTC99
    await post('/api/push/subscribe', { endpoint: keys.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } });
235ce394f90datagen2026-10-26 06:54 UTC100
  }
235ce394f90datagen2026-10-26 06:54 UTC101
  writeStored(account, { endpoint: keys.endpoint, pushkey: keys.p256dh });
235ce394f90datagen2026-10-26 06:54 UTC102
}
235ce394f90datagen2026-10-26 06:54 UTC103
235ce394f90datagen2026-10-26 06:54 UTC104
async function dropStale(endpoint: string): Promise<void> {
235ce394f90datagen2026-10-26 06:54 UTC105
  const reply = await runCommand({ op: 'pushers' });
235ce394f90datagen2026-10-26 06:54 UTC106
  for (const pushkey of stalePushers(reply, browserId(), endpoint)) {
235ce394f90datagen2026-10-26 06:54 UTC107
    await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey });
235ce394f90datagen2026-10-26 06:54 UTC108
  }
235ce394f90datagen2026-10-26 06:54 UTC109
}
235ce394f90datagen2026-10-26 06:54 UTC110
235ce394f90datagen2026-10-26 06:54 UTC111
async function subscribe(reg: ServiceWorkerRegistration): Promise<PushSubscription> {
235ce394f90datagen2026-10-26 06:54 UTC112
  return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: await applicationServerKey() });
235ce394f90datagen2026-10-26 06:54 UTC113
}
235ce394f90datagen2026-10-26 06:54 UTC114
235ce394f90datagen2026-10-26 06:54 UTC115
async function activate(account: string, sub: PushSubscription): Promise<void> {
235ce394f90datagen2026-10-26 06:54 UTC116
  const keys = subscriptionKeys(sub);
235ce394f90datagen2026-10-26 06:54 UTC117
  if (!keys) throw new Error('subscription has no keys');
235ce394f90datagen2026-10-26 06:54 UTC118
  await register(account, keys);
235ce394f90datagen2026-10-26 06:54 UTC119
}
235ce394f90datagen2026-10-26 06:54 UTC120
235ce394f90datagen2026-10-26 06:54 UTC121
/** Runs inside the settings toggle after permission is granted. */
235ce394f90datagen2026-10-26 06:54 UTC122
export function enablePush(account: string): Promise<void> {
235ce394f90datagen2026-10-26 06:54 UTC123
  return serial(async () => {
235ce394f90datagen2026-10-26 06:54 UTC124
    try {
235ce394f90datagen2026-10-26 06:54 UTC125
      const reg = await registration();
235ce394f90datagen2026-10-26 06:54 UTC126
      if (!reg) return;
235ce394f90datagen2026-10-26 06:54 UTC127
      await activate(account, (await reg.pushManager.getSubscription()) ?? (await subscribe(reg)));
235ce394f90datagen2026-10-26 06:54 UTC128
      pushActive.set(true);
235ce394f90datagen2026-10-26 06:54 UTC129
    } catch {
235ce394f90datagen2026-10-26 06:54 UTC130
      pushActive.set(false);
235ce394f90datagen2026-10-26 06:54 UTC131
    }
235ce394f90datagen2026-10-26 06:54 UTC132
  });
235ce394f90datagen2026-10-26 06:54 UTC133
}
235ce394f90datagen2026-10-26 06:54 UTC134
235ce394f90datagen2026-10-26 06:54 UTC135
/** Removes the server side registration and the browser subscription. Used by the toggle and before logout. */
235ce394f90datagen2026-10-26 06:54 UTC136
export function disablePush(account: string): Promise<void> {
235ce394f90datagen2026-10-26 06:54 UTC137
  return serial(async () => {
235ce394f90datagen2026-10-26 06:54 UTC138
    pushActive.set(false);
235ce394f90datagen2026-10-26 06:54 UTC139
    const stored = readStored(account);
235ce394f90datagen2026-10-26 06:54 UTC140
    if (stored) await removeRemote(stored).catch(() => undefined);
235ce394f90datagen2026-10-26 06:54 UTC141
    writeStored(account, null);
235ce394f90datagen2026-10-26 06:54 UTC142
    const sub = await (await registration())?.pushManager.getSubscription();
235ce394f90datagen2026-10-26 06:54 UTC143
    await sub?.unsubscribe().catch(() => false);
235ce394f90datagen2026-10-26 06:54 UTC144
  });
235ce394f90datagen2026-10-26 06:54 UTC145
}
235ce394f90datagen2026-10-26 06:54 UTC146
235ce394f90datagen2026-10-26 06:54 UTC147
/** Launch check. iOS revokes subscriptions after missed notifications and push services rotate endpoints. */
235ce394f90datagen2026-10-26 06:54 UTC148
export function syncPush(account: string, wanted: boolean): Promise<void> {
235ce394f90datagen2026-10-26 06:54 UTC149
  return serial(async () => {
235ce394f90datagen2026-10-26 06:54 UTC150
    try {
235ce394f90datagen2026-10-26 06:54 UTC151
      const reg = supported && Notification.permission === 'granted' ? await registration() : null;
235ce394f90datagen2026-10-26 06:54 UTC152
      if (!reg) {
235ce394f90datagen2026-10-26 06:54 UTC153
        pushActive.set(false);
235ce394f90datagen2026-10-26 06:54 UTC154
        return;
235ce394f90datagen2026-10-26 06:54 UTC155
      }
235ce394f90datagen2026-10-26 06:54 UTC156
      let sub = await reg.pushManager.getSubscription();
235ce394f90datagen2026-10-26 06:54 UTC157
      const action = syncAction(wanted, sub?.endpoint ?? null, readStored(account));
235ce394f90datagen2026-10-26 06:54 UTC158
      if (action === 'subscribe') sub = await subscribe(reg);
235ce394f90datagen2026-10-26 06:54 UTC159
      if (action !== 'none' && sub) await activate(account, sub);
235ce394f90datagen2026-10-26 06:54 UTC160
      pushActive.set(wanted && sub !== null);
235ce394f90datagen2026-10-26 06:54 UTC161
      if (__MALICE_DEVICE__ && wanted && sub) await dropStale(sub.endpoint).catch(() => undefined);
235ce394f90datagen2026-10-26 06:54 UTC162
    } catch {
235ce394f90datagen2026-10-26 06:54 UTC163
      pushActive.set(false);
235ce394f90datagen2026-10-26 06:54 UTC164
    }
235ce394f90datagen2026-10-26 06:54 UTC165
  });
235ce394f90datagen2026-10-26 06:54 UTC166
}