Back to Home

Architecture

Overview

My SPACE is a Chrome MV3 extension with three execution contexts:

┌─────────────────────────────────────────────────────────┐
│  Side Panel  (React UI)                                  │
│  src/sidepanel/                                          │
│  Renders views, sends messages to service worker         │
└────────────────────────┬────────────────────────────────┘
                         │ chrome.runtime.sendMessage
┌────────────────────────▼────────────────────────────────┐
│  Service Worker  (message router + OAuth)                │
│  src/service-worker/index.ts                             │
│  Routes DB messages to offscreen, handles sync/OAuth     │
└────────────────────────┬────────────────────────────────┘
                         │ chrome.runtime.sendMessage
┌────────────────────────▼────────────────────────────────┐
│  Offscreen Document  (database host)                     │
│  src/offscreen/                                          │
│  Runs PGlite (PostgreSQL WASM), handles crypto           │
└─────────────────────────────────────────────────────────┘

The offscreen document is needed because Chrome service workers cannot run WebAssembly (PGlite). The service worker creates the offscreen document on demand and proxies database messages to it.

Project Structure

Source tree
file tree
src/
├── service-worker/
│   └── index.ts          # Message router, OAuth flows, Drive sync
├── offscreen/
│   ├── main.ts           # Offscreen entry — wires chrome.runtime.onMessage
│   ├── handler.ts        # Dispatches messages to db.ts / crypto.ts
│   ├── db.ts             # PGlite schema + CRUD for all 8 tables
│   ├── crypto.ts         # AES-GCM encrypt/decrypt via Web Crypto API
│   └── polyfill.ts       # WASM/PGlite environment polyfills
├── sidepanel/
│   ├── App.tsx           # Root — icon rail, view routing, idle lock, setup/lock screens
│   ├── views/
│   │   ├── NotesView.tsx
│   │   ├── KeyvaultView.tsx
│   │   ├── GeneratorView.tsx
│   │   ├── SubscriptionsView.tsx
│   │   ├── ReportsView.tsx
│   │   ├── TodoView.tsx
│   │   ├── MapPinsView.tsx
│   │   ├── SyncView.tsx
│   │   └── SettingsView.tsx
│   ├── components/
│   │   ├── IconRail.tsx
│   │   ├── NoteCard.tsx
│   │   ├── SecretCard.tsx
│   │   ├── TagInput.tsx
│   │   ├── IconPicker.tsx
│   │   └── icons/index.tsx
│   └── index.css
├── content/
│   ├── mapExtractor.ts
│   └── savePrompt.ts
├── lib/
│   ├── renderMarkdown.ts
│   ├── generatePassword.ts
│   ├── parseImport.ts
│   ├── nextBilling.ts
│   ├── currency.ts
│   └── shareLink.ts
└── shared/
    └── messages.ts

Message Protocol

IPC

All communication uses chrome.runtime.sendMessage. Every message has a type string and optional payload. Every response has { ok: boolean, data?, error? }.

Types are defined in src/shared/messages.ts. Adding a new message type follows a four-step pattern:

  1. 1
    Add Msg<'MY_TYPE', PayloadType> to messages.ts and include it in the AnyMsg union.
    2
    Handle it in src/offscreen/handler.ts (for DB operations) or src/service-worker/index.ts (for everything else).
    3
    Call it from the side panel via the sendMsg prop.
typescript
// In a view component
const res = await sendMsg('MY_TYPE', { foo: 'bar' })
if (res.ok) console.log(res.data)

Database

PGlite · WASM

PGlite runs inside the offscreen document. Schema is initialised in src/offscreen/db.tsinitDb().

Table Purpose
notes Markdown notes with tags and embedded images (base64 data URLs)
secrets AES-GCM encrypted credentials (ciphertext + IV stored, value never plaintext) plus url and description columns
subscriptions Recurring billing tracker with currency, cycle, active toggle, logo
bills Actual paid bills per subscription per month (composite PK: sub_id, year, month)
todo_lists Colour-coded, icon-tagged task lists
todo_tasks Tasks with priority, due date, recurrence (FK to todo_lists, CASCADE delete)
map_stacks Colour-coded, icon-tagged pin collections
map_pins Location pins with lat/lng, category, priority, rating, review (FK to map_stacks, CASCADE delete)

All CRUD functions are in db.ts and called from handler.ts. The vault (secrets table) is only accessible after VAULT_UNLOCK sets the encryption key in memory.

The exportAllRows() function serialises all 8 tables for sync, and importRows() performs upsert-by-updated_at conflict resolution — last-write-wins for notes/secrets/subs/tasks, first-write-wins for stacks/pins.

Content Scripts

Browser integration

My SPACE ships two content scripts that live in src/content/ and are registered in manifest.json.

mapExtractor.ts — floating Pin button on map pages

Matches: google.com/maps, maps.google.com, openstreetmap.org, bing.com/maps, maps.apple.com.

1
On load, scans the URL for lat/lng patterns (/@lat,lng, ll=lat,lng, map=zoom/lat/lng, cp=lat~lng, !3dlat!4dlng, lat=…&lng=…).
2
If coords are found, injects a floating "Pin to My SPACE" button at bottom: 220px right: 16px.
3
A small "×" collapse button next to it shrinks the badge to an icon-only state.
4
On click, the button sends { type: 'MAP_PIN_CAPTURE', payload: ExtractedPin } to the service worker.
5
The service worker forwards { type: 'MAP_PIN_FROM_PAGE', payload } to the side panel.
6
MapPinsView listens for MAP_PIN_FROM_PAGE — when received, it sets pendingPin and addMode='page' so the confirm form is pre-filled.

A MutationObserver re-scans the URL on SPA navigation.

savePrompt.ts — Save Password badge on login forms

The script wants to run on every login form on the web, which would normally trigger Chrome Web Store's "Broad Host Permissions" review. We avoid that by registering the script dynamically with chrome.scripting.registerContentScripts only after the user opts in.

Permissions design

The bundle path savePrompt.js is generated by Vite with a non-hashed filename and listed in web_accessible_resources. <all_urls> is declared under optional_host_permissions, not host_permissions. The Settings view shows a "Save Password Prompt" card to let the user opt in.

1
Scans the page for <input type="password"> fields.
2
Listens for input events on both fields (debounced 500 ms).
3
When both fields have non-empty values (password ≥ 4 chars), shows a floating orange "Save to My SPACE?" badge.
4
On click, captures { url, username, password, formAction? } and sends SAVE_PASSWORD_OFFER.
5
The service worker forwards SAVE_PASSWORD_OFFER_FROM_PAGE to the side panel.
6
KeyvaultView listens and renders a confirm card.

Crypto

AES-GCM · PBKDF2

src/offscreen/crypto.ts wraps the Web Crypto API with four capabilities:

Vault encryption AES-GCM 256-bit, PBKDF2 600 000 iter / SHA-256
IV Random 12-byte per secret
Sync encryption AES-GCM, same vault key, random 12-byte IV per export
Cross-device decrypt SYNC_DECRYPT_WITH_SALT — re-derives temporary key from backup salt
Salt storage 16-byte random salt in chrome.storage.local as vaultSalt
Key persistence Memory only — never written to storage, wiped on lock
Auto-lock Default 15 min inactivity, configurable in Settings
Security note

The vault key is held in memory in the offscreen document only. It is never written to storage. Locking the vault clears it. Cross-device pull with a mismatched salt uses SYNC_DECRYPT_WITH_SALT to re-derive a temporary key — this derived key is never cached.

Google Drive Sync

OAuth · appData

Handled entirely in src/service-worker/index.ts.

OAuth uses chrome.identity.getAuthToken with three scopes: drive.appdata, userinfo.email, userinfo.profile.

Push flow: DB_EXPORT → encrypt → upload to Drive appDataFolder (file: keyvault-backup.json)
Pull flow: download from Drive → decrypt → DB_IMPORT
Cross-device pull: if the backup's salt differs from the local salt (different device/profile), the user is prompted for their master password. SYNC_DECRYPT_WITH_SALT re-derives the key using the backup's salt, decrypts, then imports.
Token refresh: on 401 responses, the cached token is cleared and a fresh silent token is fetched automatically.

Design System

Glass-card dark UI

Glass-card dark UI. Key CSS variables in src/sidepanel/index.css. Tailwind v4 is available for layout and spacing. Use the glass-card class for cards.

css
/* Base */
--bg-base:    #0d1117;
--glass-bg:   rgba(255,255,255,0.06);
--glass-border: rgba(255,255,255,0.08);

/* Feature accents */
--accent-notes:   #6366f1;   /* indigo */
--accent-vault:   #f59e0b;   /* amber */
--accent-sync:    #3b82f6;   /* blue */
--accent-gen:     #a78bfa;   /* violet */
--accent-subs:    #34d399;   /* emerald */
--accent-reports: #f472b6;   /* pink */
--accent-todo:    #38bdf8;   /* sky */
--accent-maps:    #fb923c;   /* orange */
--accent-notes #6366f1
--accent-vault #f59e0b
--accent-sync #3b82f6
--accent-gen #a78bfa
--accent-subs #34d399
--accent-reports #f472b6
--accent-todo #38bdf8
--accent-maps #fb923c

Android App

Kotlin · Compose

The android/ directory contains a Kotlin + Jetpack Compose app that mirrors the Chrome extension's feature set.

Layer Technology
UI Jetpack Compose + Material3, HorizontalPager navigation
Database Room (SQLite), 8 entities, 6 migrations (v1→v7)
Crypto Android Keystore AES-GCM (hardware-backed)
Sync Google Drive REST API via OkHttp + OAuth implicit flow
Images Coil async image loading

Key files

  • crypto/CryptoManager.kt Keystore-backed AES-GCM encrypt/decrypt
  • data/AppDatabase.kt Room database with all entities, DAOs, and migrations
  • sync/DriveRepository.kt Drive push/pull with multipart upload
  • ui/MySpaceApp.kt Root composable with pager navigation and accent glows
  • ui/screens/ 8 screen composables — one per feature
  • ui/theme/Theme.kt Dark colour scheme with per-feature accent colours
  • util/BillingCalc.kt Monthly equivalent USD conversion and chart data builder

Testing

Vitest · jsdom
bash
npm test

Tests live in tests/. Currently covers:

  • renderMarkdown
  • parseImport
  • generatePassword
  • nextBilling
  • crypto

The test environment is Vitest with jsdom.

Adding a New View

Extension guide
1
Create src/sidepanel/views/MyView.tsx — accept { sendMsg } as props.
2
Add an icon to src/sidepanel/components/icons/index.tsx.
3
Add the view name to the View type in App.tsx and add it to the icon rail in IconRail.tsx.
4
Import and render it in App.tsx (add to the GATED_VIEWS set if it requires vault unlock).
5
Add message types to src/shared/messages.ts and handle in handler.ts.

Versioning & Release

Deployment

Version is kept in sync between package.json and manifest.json via scripts/bump.js.

bash
npm run release:patch   # bump + build + zip

The zip file is named my-space-<version>.zip and is ready for Chrome Web Store upload.

Release checklist
  • Run npm test — all tests green
  • Bump version with npm run release:patch (or :minor / :major)
  • Verify the zip contains the correct manifest.json version
  • Upload to Chrome Web Store Developer Dashboard