Skip to content

Latest commit

 

History

History
174 lines (130 loc) · 7.33 KB

File metadata and controls

174 lines (130 loc) · 7.33 KB

Lik — Encrypted, real‑time scoreboards over Nostr

Collaborative scoreboards you can share with a QR code, sync in real time across devices, and keep end‑to‑end encrypted. Built with Vue 3 + Vite, powered by Nostr relays, and packaged for Android with Capacitor.

Open source, made by DevForth.


Get it on Google Play



Lik icon

Highlights

  • Real‑time collaborative scoreboards (CRDT‑based) that converge across peers
  • End‑to‑end encryption (AES‑GCM) for board metadata, join requests, and snapshots
  • Easy invite and onboarding via share code and QR (format: lik::<id>::<secret>)
  • Owner‑approved editors; guests see read‑only until approved
  • Offline‑friendly: local state in IndexedDB with automatic persistence
  • Android app (Capacitor) + PWA, responsive UI with Tailwind
Details
<img src="assets/Untitled.png" alt="Main UI" width="640" />

You can see live screenshots on the Play Store listing: https://play.google.com/store/apps/details?id=io.devforth.lik&hl=en

Features

  • Scoreboards
    • Create multiple boards; add, rename, and reorder categories
    • Add/rename/remove participants
    • Increment/decrement per‑participant scores per category
    • One‑tap “priority/star” per category using a CRDT shadow counter (::prio)
  • Sharing & access control
    • Share code & QR: lik::<boardId>::<secret>
    • Owner receives encrypted join requests and can approve editors
    • Non‑approved users have read‑only access after scanning the QR
  • Security & privacy
    • End‑to‑end encrypted join requests, board metadata PRE (lik::brd::<id>), and CRDT snapshots (lik::crdt::<id>) using AES‑GCM
    • Secret is derived locally and never published to relays
    • Local storage only: IndexedDB holds boards, snapshots, secrets, and minimal caches
  • Nostr integration
    • Uses public relays with nostr-tools SimplePool
    • Profiles (kind 0) published/de‑duplicated per relay; avatars generated locally
    • PREs (kind 30078) carry encrypted board metadata and CRDT snapshots
  • UX niceties
    • Top‑of‑screen activity toasts for remote edits
    • Backup reminders for your private key (nsec), key reveal & import
    • Mobile camera QR scanner with graceful fallback to manual entry

Architecture (brief)

Tech stack

  • Vue 3 + TypeScript + Vite (src/)
  • Pinia for state stores (src/stores/*)
  • Tailwind CSS + Reka UI + Vaul for the UI
  • Nostr via nostr-tools SimplePool (src/nostr.ts)
  • Capacitor for Android packaging (capacitor.config.ts, android/)

Data & persistence

  • IndexedDB via a small wrapper (src/lib/idb.ts)
    • Stores: user, scoreboards, profiles, rejectedScoreboardRequests
  • No server/database: Nostr relays are used as the transport and durable broadcast channel

End‑to‑end encryption

  • Each board has a symmetric secret: sha512Hex("${ownerPrivHex}::${boardId}")
  • What’s encrypted with that secret:
    • Join requests (kind 1 note content)
    • Board metadata PRE: d = lik::brd::<id> (content = encrypted JSON)
    • CRDT snapshot PRE: d = lik::crdt::<id> (content = encrypted JSON)
  • Secret is stored only locally with the board object in IndexedDB and never published to Nostr

Nostr events and channels

  • Profiles: kind 0 metadata published and verified per‑relay
  • Join requests: kind 1 notes with tag t = lik::sb-join-req::<id> (encrypted body)
  • PREs: kind 30078 with d tags lik::brd::<id> and lik::crdt::<id>
  • Default relays: see RELAYS in src/nostr.ts

CRDT model (scoreboards)

  • ScoreboardCRDT (src/crdt.ts)
    • Per‑category PN‑counters per participant for scores
    • Last‑writer‑wins (by timestamp) for category name, vis, order
    • Event log embedded in snapshot for lightweight activity feed
    • Priority is implemented as a shadow PN‑counter at key ${categoryKey}::prio
  • Snapshots are serialized canonically and encrypted before being published as PREs

Mobile & build

  • Capacitor Android app id io.devforth.lik; QR scanning via native camera (Browser BarcodeDetector)
  • Dockerized Android release build in build/Dockerfile and build/build.sh -> .aab output in dist-android/

Key modules (by file)

  • src/nostr.ts — relay pool, publish/subscribe helpers, PRE utilities
  • src/nostrToCRDT.ts — bridges Nostr PREs to local CRDT and back
  • src/stores/* — Pinia stores: user, scoreboards, profiles, reminders, etc.
  • src/lib/utils.ts — hashing, canonical JSON, AES‑GCM helpers, short ids
  • src/lib/idb.ts — IndexedDB wrapper
  • src/time-sync.ts — worldtimeapi‑based UTC time alignment

Getting started (local)

Prerequisites

  • Node.js 24+ (nvm use picks it from .nvmrc; enforced by package.json engines)
  • pnpm (corepack enable picks the version from package.json)

Install & run (web/PWA)

  1. Install deps
    • pnpm install
  2. Start dev server
    • pnpm dev
  3. Open the printed local URL (Vite default is http://localhost:5173)

Build (web)

  • Type check + build: pnpm build
  • Preview build locally: pnpm preview

Run as PWA

  • Open in a modern browser, then “Install”/“Add to home screen”. Icons & manifest are included under public/.

Android (Capacitor)

Option A — Android Studio

  1. Build the web app: pnpm build
  2. Sync native project: pnpm exec cap sync android
  3. Open android/ in Android Studio and run on device/emulator or assemble a release

Option B — Docker one‑shot release build

  1. Ensure Docker and a JDK (keytool, jarsigner) are available
  2. Run the helper script: AAB_PASS=xxxxxx build/build.sh
  3. Find the signed .aab in dist-android/ and upload it to Play Console

AAB_PASS is the password of the upload keystore build/my-release-key.jks (git-ignored: keep a backup of the file and the password; store and key password must be the same). Without it the bundle stays unsigned and Play Console rejects it ("All uploaded bundles must be signed"). Lost the password or the keystore? Play Console → App integrity → request an upload key reset.

Play Store

Security model (E2EE)

Lik keeps your board content private by encrypting everything sensitive end‑to‑end:

  • Secret derivation: sha512Hex("${ownerPrivHex}::${boardId}")
  • Encrypted with AES‑GCM using the derived secret:
    • Join requests (content of kind‑1 events)
    • Board metadata PRE (d = lik::brd::<id>, content = encrypted JSON)
    • CRDT snapshot PRE (d = lik::crdt::<id>, content = encrypted JSON)
  • The secret never leaves the device. Only the PRE d tag reveals the board id; contents remain opaque on relays.
  • Recovery: knowing boardId and the secret allows decrypting historical PREs.

Development notes

  • State is persisted to IndexedDB; stores debounce saves to reduce churn.
  • Profile metadata (kind 0) is published only to relays that need updates (hash comparison).
  • CRDT snapshot publishes are debounced; rapid score taps aggregate into +N/-N log entries.
  • Time is normalized from worldtimeapi.org to improve LWW conflict resolution.

Contributing

Issues and pull requests are welcome. Please keep PRs focused and include a brief rationale and testing notes.

License

MIT © Devforth.io — see LICENSE.