Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5532342
Removed pycache from repo. Added account management screen. Fixed iss…
colemanirby Jun 11, 2026
4fe346c
Add db warmup to loading screen
colemanirby Jun 11, 2026
7264efb
Indices are now built on startup. TODO: caching
colemanirby Jun 11, 2026
2c2048a
Modify wildcard to get feat/whatever branches
colemanirby Jun 11, 2026
a065ad6
Added percentage based progress bar to let users know that indexing i…
colemanirby Jun 11, 2026
8c6223b
Added ability for users to enter app while index is building. Account…
colemanirby Jun 12, 2026
659028a
Loading screen window drag bug fixed
colemanirby Jun 12, 2026
7cfd354
Certificate signing added to workflow for Windows
colemanirby Jun 12, 2026
a4d25f8
Move application to use Fangorn's privy account.
colemanirby Jun 12, 2026
5eccbd9
Moved from portable signing to NSIS
colemanirby Jun 12, 2026
d17d067
Add bug reporting mechanism
colemanirby Jun 13, 2026
1c6358a
Add dark mode
colemanirby Jun 15, 2026
534972a
Changed color scheme slightly to make dark mode less muted. Added dar…
colemanirby Jun 15, 2026
3b927e4
Added SQL DB. Updated Library view to be more useful. Users can now b…
colemanirby Jun 15, 2026
712361e
Add ability to favortie artists, albums, and tracks. Add ability to m…
colemanirby Jun 16, 2026
8107ad8
Added taxonomy for local data
colemanirby Jun 16, 2026
1518367
There is now an indicator when a track has its taxonomy data.
colemanirby Jun 16, 2026
2c7ab21
We now indicate local tracks that are in the QDrant DB
colemanirby Jun 16, 2026
7db202a
Added a Home tab and a Search tab
colemanirby Jun 16, 2026
bfd938c
We now check if a download needs to occurl. If it doesn't we check if…
colemanirby Jun 16, 2026
e35b593
Made it much easier to add Artist Images and Album Art for collections
colemanirby Jun 16, 2026
4f5baa5
Add ability to import music from another folder/drive
colemanirby Jun 16, 2026
b0e8bb8
Added a way to bulk import album art.
colemanirby Jun 17, 2026
8164605
build snapshot from pieces
driemworks Jun 17, 2026
9269f11
Made bulk art imports more efficient
colemanirby Jun 17, 2026
cb01495
server works over 10M tracks!
driemworks Jun 17, 2026
6b0b5b3
update gitignore
driemworks Jun 17, 2026
8395d22
Merge branch 'feat/snapshot' of github.com:fangorn-network/sonder int…
colemanirby Jun 18, 2026
ffa02cd
Added the ability for users to choose to download the snapshot
colemanirby Jun 18, 2026
ba0d61b
Fixed accidental yarn.lock file commit
colemanirby Jun 18, 2026
8d98cba
Remove local file from being tracked by github
colemanirby Jun 19, 2026
d15ecb1
added comments for agents that we use NUL byte separators
colemanirby Jun 19, 2026
bef5a79
Moved deezer functionality into deezer.ts
colemanirby Jun 19, 2026
f231ce0
Moved privy app id into .env
colemanirby Jun 19, 2026
d6eb03b
Add proper handling when content-type is missing
colemanirby Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Build
on:
push:
tags: ['v*']
branches: ['*']
branches: ['**']
workflow_dispatch:

env:
Expand Down Expand Up @@ -157,6 +157,12 @@ jobs:
working-directory: app
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Azure Trusted Signing (Windows). Consumed by electron-builder's
# win.azureSignOptions via the Entra ID EnvironmentCredential.
# Unused on Linux/macOS builds.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
run: yarn run ${{ matrix.build_cmd }} --publish never ${{ matrix.arch == 'arm64' && '--arm64' || '' }}

- name: Upload Electron build
Expand Down
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ db/
**/.venv**
**/venv**
**/db/*
# ...but app/src/main/db is source (SQLite schema/migrations), not a data dir.
!app/src/main/db/
!app/src/main/db/**
**/.yarn/*
!**/.yarn/releases
!**/.yarn/patches
Expand All @@ -42,4 +45,8 @@ db/
!**/.yarn/versions
**/resources/
**/qdrant_storage
.qdrant-initialized
.qdrant-initialized
**/__pycache__/
*.pyc
*.pyo
**/.claude/**
16 changes: 16 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,25 @@
VITE_GRAPH_API_KEY=
VITE_ARBITRUM_SEPOLIA_RPC_URL=

# ── Network / wallet (see src/renderer/src/lib/network.ts) ────────────────────
# Active chain + USDC contracts. Defaults to arbitrumSepolia (testnet) on every
# build until we deploy on Arbitrum One. Set to override (e.g. arbitrum).
# VITE_NETWORK=arbitrumSepolia # or: arbitrum
# VITE_ARBITRUM_RPC_URL= # mainnet RPC (defaults to public arb1 endpoint)

# ── Agent ────────────────────────────────────────────────────────────────────
VITE_USE_AGENT=true

# ── Privy (auth / embedded wallets) ──────────────────────────────────────────
# App ID from the Privy dashboard (used in src/renderer/src/main.tsx).
VITE_PRIVY_APP_ID=

# ── Bug reports (in-app "report a problem") ──────────────────────────────────
# URL of the serverless proxy that files GitHub issues (it holds the GH token so
# we never ship it in the app — see tools/bug-report-worker/). Leave blank and
# the reporter falls back to opening a prefilled GitHub issue in the browser.
VITE_BUGREPORT_ENDPOINT=

# ── Spotify (playback / OAuth PKCE) ──────────────────────────────────────────
VITE_SPOTIFY_CLIENT_ID=
VITE_SPOTIFY_CLIENT_SECRET=
Expand Down
7 changes: 4 additions & 3 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ An Electron application with React and TypeScript
Requirements: **Node 22** (Corepack enables Yarn 4), **Python 3.12+**, and `tar` on your PATH. `ffmpeg` is optional (audio extraction).

```bash
yarn install # deps + native-module rebuild
yarn dev # first run auto-provisions the dev backend, then launches
yarn install
yarn setup
yarn dev
```

That's the whole flow. The first `yarn dev` runs a one-time setup (`scripts/setup.mjs`, also available as `yarn setup`) that provisions everything a fresh clone is missing, **per-OS**:
That's the whole flow. The first `yarn dev` runs a one-time setup that provisions everything a fresh clone is missing, **per-OS**:

- **`.env`** — copied from `.env.example`. Fill in real values to enable auth / Firebase / Spotify / catalog features; the app still launches with blanks.
- **yt-dlp** → `resources/bin/<os>/`.
Expand Down
20 changes: 18 additions & 2 deletions app/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,19 @@ extraResources:
win:
executableName: SOND3R
target:
- target: portable
- target: nsis
arch: [x64]
artifactName: ${name}.${ext}
# Azure Trusted Signing. electron-builder installs the `TrustedSigning`
# PowerShell module on the runner and signs via `Invoke-TrustedSigning`,
# authenticating through Microsoft Entra ID env vars set in CI:
# AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET (a service principal
# holding the "Trusted Signing Certificate Profile Signer" role).
# The four values below are NOT secrets and belong in source control.
azureSignOptions:
endpoint: https://scus.codesigning.azure.net/
codeSigningAccountName: SOND3R
certificateProfileName: prod-signing
publisherName: Fangorn

mac:
executableName: SOND3R
Expand All @@ -53,10 +63,16 @@ linux:
appImage:
artifactName: ${name}.${ext}
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
# Keep wallet/account + vector index data when the app is uninstalled.
# (false is the electron-builder default; set explicitly so a future edit
# doesn't silently start wiping users' crypto account data.)
deleteAppDataOnUninstall: false
npmRebuild: false
nodeGypRebuild: false
detectUpdateChannel: false
Expand Down
5 changes: 4 additions & 1 deletion app/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ export default defineConfig({
main: {
build: {
rollupOptions: {
external: ['dbus-final', 'x11']
// better-sqlite3 ships a native .node binary (can't be bundled);
// music-metadata is ESM-only and loaded via dynamic import(). Keep both
// external so they're required from node_modules at runtime.
external: ['dbus-final', 'x11', 'better-sqlite3', 'music-metadata']
}
}
},
Expand Down
6 changes: 5 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "yarn typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"dev": "node scripts/dev.mjs",
"clean": "node scripts/clean.mjs",
"build": "yarn typecheck && electron-vite build",
"setup": "node scripts/setup.mjs",
"predev": "node scripts/setup.mjs",
Expand All @@ -36,6 +37,7 @@
"@semaphore-protocol/identity": "^4.14.2",
"@tanstack/react-query": "^5",
"@whatwg-node/fetch": "^0.10.13",
"better-sqlite3": "^12.11.1",
"boring-avatars": "^2.0.4",
"d3": "^7.9.0",
"electron-updater": "^6.3.9",
Expand All @@ -44,6 +46,7 @@
"graphql": "^16.13.2",
"mime": "^4.1.0",
"mime-types": "^3.0.2",
"music-metadata": "^11.13.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"viem": "^2.47.1",
Expand All @@ -56,6 +59,7 @@
"@electron/rebuild": "^4.0.4",
"@eslint/js": "^9.39.1",
"@graphprotocol/client-cli": "^3.0.7",
"@types/better-sqlite3": "^7.6.13",
"@types/jsmediatags": "^3.9.6",
"@types/mime-types": "^3.0.1",
"@types/node": "^24.10.1",
Expand Down
112 changes: 112 additions & 0 deletions app/scripts/build-snapshot-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* build-snapshot-manifest.mjs — regenerate src/main/snapshot-manifest.json
*
* The catalog snapshot .gz (~14 GB) is too large to pin to Pinata as one object,
* so it's uploaded in parts (`pinata upload-split`), each part pinned separately
* and named `<prefix>.partNNN`. A public IPFS gateway can only fetch by CID, and
* mapping those part NAMES to their CIDs needs Pinata's authenticated API — which
* the shipped app must NOT carry. So we resolve name->CID ONCE, here, and commit
* the resulting CID list. At runtime the app just reads that list and streams the
* parts back-to-back from the gateway (see loadManifest/downloadGz in index.ts).
*
* Usage:
* PINATA_JWT=... node scripts/build-snapshot-manifest.mjs <name-prefix>
* # or put PINATA_JWT in app/.env (loaded automatically)
*
* The JWT must belong to the account that holds the parts. Writes the manifest
* to src/main/snapshot-manifest.json next to index.ts.
*/
import 'dotenv/config'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const prefix = process.argv[2]
if (!prefix) {
console.error('Usage: node scripts/build-snapshot-manifest.mjs <name-prefix>')
process.exit(1)
}
const jwt = process.env.PINATA_JWT
if (!jwt) {
console.error('Error: PINATA_JWT not set (export it or add it to app/.env).')
process.exit(1)
}

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const outPath = path.join(__dirname, '..', 'src', 'main', 'snapshot-manifest.json')

// Page through every file whose name starts with `prefix`.
async function* listAll(namePrefix) {
let token
for (;;) {
const url = new URL('https://api.pinata.cloud/v3/files/public')
url.searchParams.set('name', namePrefix)
url.searchParams.set('limit', '100')
if (token) url.searchParams.set('pageToken', token)
const res = await fetch(url, { headers: { Authorization: `Bearer ${jwt}` } })
if (!res.ok) throw new Error(`Pinata list failed: ${res.status} ${await res.text()}`)
const { data } = await res.json()
for (const f of data?.files ?? []) yield f
if (!data?.next_page_token) break
token = data.next_page_token
}
}

// Parse the `.partNNN` suffix. The zero-pad WIDTH matters: one prefix can collect
// parts from more than one upload run (e.g. an aborted run that padded
// differently), so we key by width and keep only the complete contiguous set.
function parsePart(name) {
const m = /\.part(\d+)$/.exec(name ?? '')
return m ? { index: parseInt(m[1], 10), width: m[1].length } : null
}
function isComplete(arr) {
const seen = new Set(arr.map((p) => p.index))
if (seen.size !== arr.length) return false
for (let i = 0; i < arr.length; i++) if (!seen.has(i)) return false
return true
}

const byWidth = new Map()
let scanned = 0
for await (const f of listAll(prefix)) {
const p = parsePart(f.name)
if (!p) continue
if (!byWidth.has(p.width)) byWidth.set(p.width, [])
byWidth.get(p.width).push({ index: p.index, name: f.name, id: f.id, cid: f.cid, size: f.size })
process.stdout.write(`\r scanned ${++scanned} part object(s)`)
}
process.stdout.write('\n')

if (byWidth.size > 1) {
console.log(`Note: parts span ${byWidth.size} padding widths (overlapping upload runs):`)
for (const [w, arr] of byWidth) {
console.log(` width ${w}: ${arr.length} part(s)${isComplete(arr) ? ' (complete)' : ' (incomplete — ignored)'}`)
}
}
const complete = [...byWidth.values()].filter(isComplete)
if (!complete.length) {
console.error('❌ No complete contiguous part set (0..n-1) found for this prefix.')
process.exit(1)
}
// Prefer the largest complete set (the real full upload, not a short aborted one).
complete.sort((a, b) => b.length - a.length)
const parts = complete[0].slice().sort((a, b) => a.index - b.index)

const missing = parts.filter((p) => !p.cid)
if (missing.length) {
console.error(`❌ ${missing.length} part(s) have no cid — aborting.`)
process.exit(1)
}

const manifest = {
name: prefix,
total_size: parts.reduce((s, p) => s + (p.size || 0), 0),
part_size: parts.reduce((m, p) => Math.max(m, p.size || 0), 0),
parts,
rebuilt: new Date().toISOString(),
}
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n')
console.log(`✅ Wrote ${parts.length} part(s) → ${path.relative(process.cwd(), outPath)}`)
console.log(` total_size = ${(manifest.total_size / 1024 ** 3).toFixed(2)} GB`)
console.log(' Remember to update sha256 in resolveSnapshot() if the catalog changed.')
48 changes: 48 additions & 0 deletions app/scripts/clean.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// `yarn clean` — wipe the app's persisted state so the next `yarn dev` behaves
// like a first-ever launch: re-downloads + recovers the Qdrant catalog snapshot,
// rebuilds the warmup indexes, starts with an empty local DB, and a fresh login.
//
// This removes only Electron's userData directory (per-OS app-data location).
// Dev build assets — the Qdrant engine binary, yt-dlp, the Python venv, .env —
// live in resources/ and vectordb/ and are provisioned by setup.mjs, NOT here,
// so a clean stays fast and doesn't re-download the toolchain.
//
// Close the app (and its Qdrant/python sidecars) before running this.
import { rmSync, existsSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { homedir } from 'node:os'
import { fileURLToPath } from 'node:url'

const APP = join(dirname(fileURLToPath(import.meta.url)), '..')
// Electron names userData after app.getName(), which defaults to package.json name.
const appName = JSON.parse(readFileSync(join(APP, 'package.json'), 'utf8')).name

if (!appName) {
console.error('✗ could not read "name" from package.json — refusing to guess the userData path.')
process.exit(1)
}

function userDataDir(name) {
const home = homedir()
switch (process.platform) {
case 'win32':
return join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), name)
case 'darwin':
return join(home, 'Library', 'Application Support', name)
default:
return join(process.env.XDG_CONFIG_HOME || join(home, '.config'), name)
}
}

const dir = userDataDir(appName)

if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true })
console.log(`✓ removed app state → ${dir}`)
console.log(
' next `yarn dev` runs the first-launch flow: snapshot download + warmup, empty local DB, fresh login.'
)
} else {
console.log(`• nothing to clean — no app state at ${dir}`)
}
49 changes: 49 additions & 0 deletions app/scripts/dev.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Dev launcher with a Linux-only sandbox fallback.
//
// On Ubuntu 24.04 and *-lowlatency kernels, AppArmor restricts unprivileged
// user namespaces (kernel.apparmor_restrict_unprivileged_userns=1). Electron's
// Chromium sandbox needs such a namespace to start; when it's blocked the
// sandboxed syscalls fail and Electron dies before DevTools attaches, with a
// misleading fatal error like:
// FATAL:platform_shared_memory_region_posix.cc ... /dev/shm ... No such process (3)
//
// When we detect that restriction we disable the sandbox for `yarn dev` only.
// Production builds never run this script, so they keep the sandbox. On macOS,
// Windows, or a Linux box without the restriction, nothing changes.
import { spawn } from 'node:child_process'
import { readFileSync } from 'node:fs'

const env = { ...process.env }

function unprivilegedUsernsBlocked() {
if (process.platform !== 'linux') return false
try {
const flag = readFileSync(
'/proc/sys/kernel/apparmor_restrict_unprivileged_userns',
'utf8'
).trim()
return flag === '1'
} catch {
return false
}
}

if (unprivilegedUsernsBlocked() && !('ELECTRON_DISABLE_SANDBOX' in env)) {
env.ELECTRON_DISABLE_SANDBOX = '1'
console.log(
'[dev] AppArmor restricts unprivileged user namespaces; disabling the ' +
'Electron sandbox for dev (ELECTRON_DISABLE_SANDBOX=1). Set ' +
'ELECTRON_DISABLE_SANDBOX explicitly to override.'
)
}

const child = spawn('yarn', ['electron-vite', 'dev', ...process.argv.slice(2)], {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
})

child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal)
else process.exit(code ?? 0)
})
Loading
Loading