OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore commits / ad87a1c

Phase 3: Implement content-by-hash storage
Major Changes: - Add contentCache Hyperbee for storing post content by SHA256 hash - Update post creation (CLI + web API) to hash YAML and store in contentCache - Add contentHash field to feed entries (dual storage for Phase 4 transition) - Update timeline reading to check contentCache first, fall back to feed content - Update relay pull to store all pulled posts in contentCache by hash Bridge Improvements: - Initialize contentCache in WirecoreBridge for relay integration - Pull method now stores 600+ relay posts in local contentCache - Posts accessible by hash for future signature-only feeds (Phase 4) Bug Fixes: - Fix bare-ws Socket API: use .write() instead of .send() - Fix bare-ws Socket API: use .destroy() instead of .close() - Fix ANProto signature: pass contentHash only (sign() adds timestamp internally) Released: v4232 (pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
ad87a1c24b6f5df2dbf3f7a20801d0448b37e004
by plankatron <fleeky@gmail.com> · 2026-01-22T18:35:30.000Z

Changed files (34) +7617 -31

RELAY_INTEGRATION_PLAN.md +649 -0
……@@ -0,0 +1,649 @@
1# Wirecore Relay Integration Plan
2
3**Date**: 2026-01-22
4**Status**: Design Phase
5**Goal**: Connect P2P wirecore clients with pub.wiredove.net relay network
6
7---
8
9## 📊 SYSTEMS REVIEW
10
11### Current Architecture
12
13```
14 Internet
15 │
16 ▼
17 ┌────── 7rnx.net ──────┐
18 │ Apache2 (SSL) │
19 │ wss://7rnx.net/relay│
20 └───────┬──────────────┘
21 │
22 hyperident-gateway
23 (10.22.0.0/16 VPN)
24 │
25 ▼
26 ┌────tabertron────┐ ┌───pub.wiredove.net───┐
27 │ 10.22.0.1 │◄────────┤ WebSocket Bridge │
28 │ │ wss:// │ APDS Relay │
29 │ wirecore-relay │ │ Browser Clients │
30 │ Port 9000 │ └──────────────────────┘
31 │ │
32 │ ✓ APDS Storage │
33 │ ✓ WebSocket │
34 │ ✓ Bridge Code │
35 └─────────────────┘
36 │
37 │
38 Local Network Replication
39 │
40 ▼
41 ┌──wirecore pear-app──┐
42 │ P2P Clients │
43 │ │
44 │ ✓ Hypercore feeds │
45 │ ✓ Hyperswarm P2P │
46 │ ✓ ANProto sigs │
47 │ ✓ Directory queries │
48 │ ✗ Post to relay │
49 │ ✗ Pull from relay │
50 │ ✗ WebSocket conn │
51 └──────────────────────┘
52```
53
54### Component Analysis
55
56#### 1. wirecore-relay (tabertron:9000)
57
58**Status**: ✅ Deployed and running
59
60**Capabilities**:
61- APDS-compatible WebSocket server
62- Content-addressable blob storage (Hyperbee)
63- ANProto signature verification
64- Message gossip between connected clients
65- Bridge connection to pub.wiredove.net
66- HTTP directory API (/directory, /directory/search)
67- Message log (Hypercore append-only)
68
69**Storage**:
70- `metaBee`: Key-value metadata (keypair, etc.)
71- `blobBee`: Content blobs by SHA256 hash
72- `messageCore`: Append-only log of valid ANProto signatures
73- Hyperswarm replication for all cores
74
75**Current Issues**:
76- No incoming connections from P2P clients
77- Bridge may be disabled by default (ENABLE_BRIDGE=false?)
78- No active sync mechanism with pear-app clients
79
80#### 2. wirecore pear-app
81
82**Status**: ✅ Deployed (v4164)
83
84**Capabilities**:
85- Hypercore feed management (own + followed)
86- Hyperswarm P2P replication
87- ANProto signature generation
88- YAML post format
89- Timeline aggregation
90- HTTP directory queries to relay
91
92**Current Limitations**:
93- Posts only go to own Hypercore feed
94- No WebSocket connection to relay
95- No push to relay when posting
96- No pull from relay for bootstrapping
97- No real-time relay updates
98
99#### 3. pub.wiredove.net
100
101**Status**: ✅ External service
102
103**Capabilities**:
104- APDS WebSocket relay
105- Browser-based clients (Trystero P2P)
106- Message gossip network
107- Directory of users
108- Content-addressable storage
109
110**Protocol**: APDS over WebSocket
111- 44-char hash → content request
112- Longer message → content or signature (store + gossip)
113- Signature validation (ANProto)
114
115---
116
117## 🎯 INTEGRATION GOALS
118
119### Phase 1: Relay Push (P2P → Relay)
120
121**Goal**: When P2P clients post, also push to relay
122
123**Benefits**:
124- Posts visible to browser-based wiredove users
125- Posts stored in relay's durable storage
126- Relay can bridge to pub.wiredove.net
127- New users can bootstrap from relay
128
129### Phase 2: Relay Pull (Relay → P2P)
130
131**Goal**: P2P clients fetch relay content on demand
132
133**Benefits**:
134- See posts from wiredove.net users
135- Bootstrap new identities from relay
136- Fill gaps when P2P peers offline
137- Cross-network discovery
138
139### Phase 3: Real-Time Sync
140
141**Goal**: WebSocket connection for live updates
142
143**Benefits**:
144- Instant notification of new posts
145- Bi-directional real-time sync
146- Reduced polling overhead
147- Better UX
148
149---
150
151## 📐 DESIGN: Relay Push/Pull Architecture
152
153### Architectural Principles (holepunchto best practices)
154
1551. **Content-by-Hash**: SHA256-addressed content (already implemented)
1562. **Sparse Replication**: Only fetch what you need (feeds, not entire network)
1573. **Lazy Loading**: Load content on demand (already done for followed feeds)
1584. **Protocol Multiplexing**: Protomux for custom protocols over Hyperswarm
1595. **Graceful Degradation**: Work offline, relay is enhancement not requirement
1606. **Caching**: Local cache with relay as fallback
161
162### Integration Strategy
163
164**Key Insight**: Relay is a **bootstrap and bridge server**, NOT a central authority.
165
166```
167P2P Network (Hyperswarm) Relay Network (WebSocket) Wiredove Network
168 │ │ │
169 │ 1. Post to own feed │ │
170 ├───────────────────► │ │
171 │ │ │
172 │ 2. Push YAML + sig ────► │ 3. Bridge forward ────► │
173 │ to relay │ to wiredove.net │
174 │ │ │
175 │ 4. Query missing ────► │ │
176 │ content by hash │ │
177 │ │ │
178 │ ◄──── 5. Pull content ────│ │
179 │ from relay │ │
180```
181
182---
183
184## 🔧 IMPLEMENTATION PLAN
185
186### Phase 1: Relay Push (Week 1)
187
188**Objective**: Post to relay when posting to own feed
189
190**Changes Required**:
191
192#### 1.1 Add RelayClient Module (pear-app/lib/relay-client.js)
193
194```javascript
195/**
196 * RelayClient - Push content to wirecore relay
197 *
198 * Features:
199 * - WebSocket connection to wss://7rnx.net/relay
200 * - Push YAML content blobs
201 * - Push ANProto signatures
202 * - Automatic reconnection
203 * - Queue messages when offline
204 */
205
206export class RelayClient {
207 constructor(relayUrl = 'wss://7rnx.net/relay') {
208 this.relayUrl = relayUrl
209 this.ws = null
210 this.connected = false
211 this.messageQueue = []
212 this.reconnectAttempts = 0
213 this.maxReconnectAttempts = 10
214 }
215
216 async connect() {
217 return new Promise((resolve, reject) => {
218 this.ws = new WebSocket(this.relayUrl)
219
220 this.ws.on('open', () => {
221 this.connected = true
222 this.reconnectAttempts = 0
223 console.log('✅ Connected to relay:', this.relayUrl)
224
225 // Flush queued messages
226 while (this.messageQueue.length > 0) {
227 const msg = this.messageQueue.shift()
228 this.send(msg)
229 }
230
231 resolve()
232 })
233
234 this.ws.on('close', () => {
235 this.connected = false
236 console.log('Relay connection closed')
237 this.reconnect()
238 })
239
240 this.ws.on('error', (err) => {
241 console.error('Relay error:', err.message)
242 reject(err)
243 })
244
245 this.ws.on('message', (data) => {
246 // Handle content requests from relay
247 this.handleMessage(data.toString())
248 })
249 })
250 }
251
252 send(message) {
253 if (!this.connected) {
254 // Queue for later
255 this.messageQueue.push(message)
256 console.log('Queued message for relay (offline)')
257 return
258 }
259
260 if (this.ws.readyState === 1) {
261 this.ws.send(message)
262 console.log('📤 Sent to relay:', message.substring(0, 50))
263 }
264 }
265
266 reconnect() {
267 if (this.reconnectAttempts >= this.maxReconnectAttempts) {
268 console.error('Max reconnect attempts reached')
269 return
270 }
271
272 const backoff = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000)
273 this.reconnectAttempts++
274
275 console.log(`Reconnecting to relay in ${backoff}ms (attempt ${this.reconnectAttempts})`)
276 setTimeout(() => this.connect(), backoff)
277 }
278
279 async handleMessage(message) {
280 // If relay requests content by hash, send it if we have it
281 if (message.length === 44) {
282 // Content request - we can implement this in Phase 2
283 console.log('Relay requested content:', message)
284 }
285 }
286
287 close() {
288 if (this.ws) {
289 this.ws.close()
290 }
291 }
292}
293```
294
295#### 1.2 Integrate RelayClient into pear-app/index.js
296
297**Add imports**:
298```javascript
299import { RelayClient } from './lib/relay-client.js'
300```
301
302**Initialize relay client**:
303```javascript
304let relayClient = null
305
306// In loadState() or startSwarm()
307if (!relayClient) {
308 relayClient = new RelayClient('wss://7rnx.net/relay')
309 try {
310 await relayClient.connect()
311 } catch (err) {
312 console.warn('Failed to connect to relay:', err.message)
313 // Continue anyway - relay is optional
314 }
315}
316```
317
318**Update cmdPost() to push to relay**:
319```javascript
320async function cmdPost(text) {
321 await loadState()
322 if (!myFeed) {
323 console.error('❌ Not initialized. Run: init')
324 return
325 }
326
327 await startSwarm()
328
329 // Get username from metadata
330 const usernameNode = await metaBee.get('username')
331 const username = usernameNode ? usernameNode.value : null
332
333 // Create YAML with metadata
334 const yaml = YAMLHelper.create({
335 name: username,
336 image: null,
337 previous: null // TODO: implement threading
338 }, text)
339
340 // Hash the YAML content
341 const crypto = await import('hypercore-crypto')
342 const contentHash = crypto.hash(Buffer.from(yaml, 'utf-8')).toString('base64url')
343
344 // Create ANProto signature
345 const timestamp = Date.now().toString()
346 const signaturePayload = timestamp + contentHash
347 const signature = ANProto.sign(signaturePayload, anprotoKeypair.secretKey)
348
349 // 1. Append to own Hypercore feed
350 await myFeed.append(JSON.stringify({
351 text: yaml,
352 timestamp,
353 username
354 }))
355
356 console.log('✅ Posted to feed')
357
358 // 2. Push to relay
359 if (relayClient && relayClient.connected) {
360 // Send YAML content blob
361 relayClient.send(yaml)
362
363 // Send ANProto signature (132 bytes)
364 relayClient.send(signature)
365
366 console.log('📤 Pushed to relay')
367 } else {
368 console.warn('⚠️ Relay offline, post queued')
369 }
370
371 broadcast({ type: 'new_post', feed: 'own' })
372}
373```
374
375**Benefits**:
376- Posts now visible on wss://7rnx.net/relay
377- Relay can bridge to pub.wiredove.net
378- Graceful degradation if relay offline
379- Message queue prevents loss
380
381---
382
383### Phase 2: Relay Pull (Week 2)
384
385**Objective**: Fetch content from relay when P2P peers don't have it
386
387**Changes Required**:
388
389#### 2.1 Add ContentFetcher Module (pear-app/lib/content-fetcher.js)
390
391```javascript
392/**
393 * ContentFetcher - Fetch missing content from relay
394 *
395 * Strategy:
396 * 1. Try local Hyperbee cache
397 * 2. Try P2P peers (existing Hyperswarm connections)
398 * 3. Fallback to relay HTTP API
399 */
400
401export class ContentFetcher {
402 constructor(relayUrl = 'https://7rnx.net/relay') {
403 this.relayUrl = relayUrl
404 this.cache = new Map()
405 }
406
407 async fetchByHash(hash) {
408 // Check memory cache
409 if (this.cache.has(hash)) {
410 return this.cache.get(hash)
411 }
412
413 // Try relay HTTP endpoint
414 try {
415 const response = await fetch(`${this.relayUrl}/${hash}`)
416 if (response.ok) {
417 const content = await response.text()
418 this.cache.set(hash, content)
419 console.log('📥 Fetched from relay:', hash.substring(0, 16))
420 return content
421 }
422 } catch (err) {
423 console.warn('Failed to fetch from relay:', err.message)
424 }
425
426 return null
427 }
428}
429```
430
431#### 2.2 Use ContentFetcher in Timeline Rendering
432
433```javascript
434const contentFetcher = new ContentFetcher()
435
436async function renderTimelinePost(signature) {
437 const opened = ANProto.open(signature)
438 if (!opened) return null
439
440 const [timestamp, contentHash] = opened.split(':')
441
442 // Try to get content
443 let content = await contentCache.get(contentHash)
444
445 if (!content) {
446 // Fetch from relay
447 content = await contentFetcher.fetchByHash(contentHash)
448
449 if (content) {
450 // Cache locally
451 await contentCache.put(contentHash, content)
452 } else {
453 console.warn('Content not found:', contentHash)
454 return null
455 }
456 }
457
458 // Parse YAML and display
459 const { metadata, content: text } = YAMLHelper.parse(content)
460 return {
461 author: metadata.name || 'Unknown',
462 text,
463 timestamp: parseInt(timestamp)
464 }
465}
466```
467
468---
469
470### Phase 3: Real-Time Sync (Week 3)
471
472**Objective**: WebSocket connection for live updates
473
474**Changes Required**:
475
476#### 3.1 Enhance RelayClient with Message Handling
477
478```javascript
479// In RelayClient
480this.ws.on('message', (data) => {
481 const message = data.toString()
482
483 if (message.length === 44) {
484 // Content request from relay
485 this.handleContentRequest(message)
486 } else if (message.length === 132) {
487 // ANProto signature (new post)
488 this.handleNewPost(message)
489 } else {
490 // Content blob
491 this.handleContentBlob(message)
492 }
493})
494
495async handleNewPost(signature) {
496 console.log('🔔 New post from relay')
497
498 // Verify signature
499 const opened = ANProto.open(signature)
500 if (!opened) return
501
502 // Trigger timeline refresh
503 broadcast({ type: 'new_post', source: 'relay' })
504}
505```
506
507---
508
509## 🔐 SECURITY CONSIDERATIONS
510
5111. **Signature Verification**: Always verify ANProto signatures before displaying
5122. **Content Size Limits**: Max 1MB per message (already in relay)
5133. **Rate Limiting**: Prevent spam (relay has this)
5144. **Hash Validation**: Verify content matches its SHA256 hash
5155. **No Trust in Relay**: Relay is untrusted - verify all cryptographic proofs
516
517---
518
519## 📊 TESTING PLAN
520
521### Test 1: Relay Push
522```bash
523# Terminal 1: Start relay
524cd relay
525node wirecore-relay.js
526
527# Terminal 2: Post from pear-app
528pear run . cli post "Hello relay!"
529
530# Terminal 3: Check relay web UI
531curl http://localhost:9000/all
532# Should see the new post
533```
534
535### Test 2: Relay Pull
536```bash
537# Terminal 1: Post to relay directly (simulate wiredove user)
538echo "Test YAML content" | websocat wss://7rnx.net/relay
539
540# Terminal 2: Pull from pear-app
541pear run . cli timeline
542# Should fetch content from relay
543```
544
545### Test 3: Cross-Network
546```bash
547# Machine A: P2P client (pear-app)
548pear run . cli post "From wirecore"
549
550# Machine B: Browser client (pub.wiredove.net)
551# Open https://pub.wiredove.net/
552# Should see "From wirecore" post appear
553```
554
555---
556
557## 🚀 DEPLOYMENT STRATEGY
558
559### Stage 1: Enable Relay Bridge
560
561```bash
562# On tabertron
563cd /storage/projects/code/hypercore-kb/hyper-fleeky/repos/wirecore/relay
564export ENABLE_BRIDGE=true
565pm2 restart wirecore-relay
566```
567
568### Stage 2: Deploy RelayClient to pear-app
569
570```bash
571cd pear-app
572# Add relay-client.js
573# Update index.js
574pear stage wirecore .
575pear release wirecore
576pear seed wirecore .
577```
578
579### Stage 3: Test Integration
580
581```bash
582# Test from multiple locations
583# - tabertron (local)
584# - ashatron (remote via VPN)
585# - pub.wiredove.net (browser)
586```
587
588---
589
590## 📈 SUCCESS METRICS
591
5921. **Relay Push**: 100% of P2P posts appear in relay within 1 second
5932. **Relay Pull**: Missing content fetched from relay within 2 seconds
5943. **Cross-Network**: Wiredove users see wirecore posts (and vice versa)
5954. **Availability**: Relay online >99% (systemd auto-restart)
5965. **Performance**: <100ms latency for relay operations
597
598---
599
600## 🔮 FUTURE ENHANCEMENTS
601
6021. **Multiple Relays**: Load balance across multiple relay servers
6032. **Relay Discovery**: DHT-based relay discovery (relay announces itself)
6043. **Selective Sync**: Only sync followed users through relay
6054. **Content Pinning**: Pin important content to relay for durability
6065. **Relay Federation**: Multiple relays sync with each other
607
608---
609
610## 📚 REFERENCES
611
612- **APDS Protocol**: wirecore/docs/claude/wirecore-skills.md
613- **ANProto Spec**: wirecore/lib/anproto.js
614- **Relay Implementation**: wirecore/relay/wirecore-relay.js
615- **Hyperswarm Best Practices**: holepunchto/skills/hyperswarm/
616- **Protomux Patterns**: holepunchto/skills/protomux/
617
618---
619
620## ✅ SUMMARY
621
622**Current State**:
623- P2P network works (Hyperswarm)
624- Relay exists but isolated
625- No bi-directional sync
626
627**Target State**:
628- P2P posts flow to relay → wiredove.net
629- Relay content accessible to P2P clients
630- Real-time updates via WebSocket
631- Graceful degradation (relay optional)
632
633**Key Design Decisions**:
6341. Relay is **bootstrap + bridge**, not central authority
6352. P2P-first, relay-fallback architecture
6363. Content-by-hash for efficiency
6374. ANProto for cross-network compatibility
6385. WebSocket for real-time, HTTP for pull
639
640**Implementation Order**:
6411. Week 1: Relay push (posts flow to relay)
6422. Week 2: Relay pull (fetch missing content)
6433. Week 3: Real-time sync (WebSocket bidirectional)
644
645---
646
647**Document**: RELAY_INTEGRATION_PLAN.md
648**Author**: Claude Sonnet 4.5 + fleeky
649**Last Updated**: 2026-01-22
RELAY_INTEGRATION_PLAN_V2.md +662 -0
……@@ -0,0 +1,662 @@
1# Wirecore Relay Integration Plan V2 (Simplified)
2
3**Date**: 2026-01-22
4**Status**: Design Phase - Revised
5**Goal**: Connect P2P wirecore pear-app with relay using proven HTTP/WS patterns
6
7---
8
9## 🔍 EXISTING WORKING CODE REVIEW
10
11### Found: wiredove-cli working implementation
12
13**Location**: `/wirecore/cli/wiredove-cli/wiredove.js`
14
15**Push Pattern** (lines 341-400):
16```javascript
17// Short-lived WebSocket connection for push
18const ws = new WebSocket('wss://7rnx.net/relay/')
19
20ws.on('open', async () => {
21 ws.send(pubkey) // 1. Send author pubkey
22 ws.send(messageText) // 2. Send YAML content
23 ws.send(imageData) // 3. Send image (if present)
24 ws.send(signature) // 4. Send ANProto signature
25
26 setTimeout(() => {
27 ws.close() // Close after 2 seconds
28 }, 2000)
29})
30```
31
32**Pull Pattern** (lines 543-604):
33```javascript
34// HTTP GET for fetching all posts
35const response = await new Promise((resolve, reject) => {
36 https.get('https://7rnx.net/relay/all', (res) => {
37 let data = ''
38 res.on('data', chunk => data += chunk)
39 res.on('end', () => resolve(JSON.parse(data)))
40 })
41})
42
43// Response format:
44// [{ author: "pubkey", text: "yaml...", sig: "...", ts: 123 }, ...]
45```
46
47---
48
49## ⚖️ APPROACH COMPARISON
50
51### My Original Approach (Persistent WebSocket)
52
53**Pros**:
54- Real-time updates
55- Bidirectional communication
56- Efficient for high-frequency updates
57
58**Cons**:
59- ❌ bare-ws has browser compatibility issues (error 1006)
60- ❌ bare-http might have similar issues
61- ❌ Complex state management (reconnection, queuing)
62- ❌ Doesn't match "refresh UX works for now" requirement
63- ❌ Over-engineered for current needs
64- ❌ More attack surface (persistent connection)
65
66### User's Suggested Approach (HTTP + Short-Lived WS)
67
68**Pros**:
69- ✅ Simple and proven (wiredove-cli works)
70- ✅ Avoids bare-ws browser issues (only outgoing WS)
71- ✅ HTTP pull works everywhere (bare-http, node https)
72- ✅ Matches existing UX (manual refresh)
73- ✅ Less complex (stateless)
74- ✅ Configurable relay URL
75- ✅ Graceful degradation
76
77**Cons**:
78- Manual refresh required (but that's acceptable)
79- Slightly higher overhead per push (connect/disconnect)
80- No real-time notifications
81
82**Verdict**: User's approach is better for current requirements.
83
84---
85
86## 🎯 REVISED PLAN: HTTP-First with Configurable Relay
87
88### Core Principles
89
901. **HTTP-first**: Use bare-http / node https (known to work)
912. **Short-lived WS**: Only for push, immediately close
923. **Configurable relay**: Support both pub.wiredove.net and 7rnx.net/relay
934. **Manual refresh**: User triggers timeline updates
945. **Graceful degradation**: Relay failure doesn't break P2P
956. **Simple**: No persistent state, no reconnection logic
96
97---
98
99## 📐 ARCHITECTURE (Simplified)
100
101```
102┌─────────────────────────────────────────┐
103│ Wirecore Pear-App │
104│ │
105│ ┌──────────────┐ ┌────────────────┐│
106│ │ Own Feed │ │ Followed Feeds ││
107│ │ (Hypercore) │ │ (Hyperswarm) ││
108│ └──────┬───────┘ └────────┬───────┘│
109│ │ │ │
110│ │ POST │ GET │
111│ ▼ ▼ │
112│ ┌─────────────────────────────────┐ │
113│ │ Relay Client (HTTP/WS) │ │
114│ │ • configurable relay URL │ │
115│ │ • push(): short WS connection │ │
116│ │ • pull(): HTTP GET /all │ │
117│ └──────────┬──────────────────────┘ │
118└─────────────┼─────────────────────────┘
119 │
120 Internet│
121 ▼
122 ┌──────────────────────┐
123 │ Configurable Relay │
124 │ │
125 │ pub.wiredove.net │
126 │ OR │
127 │ 7rnx.net/relay │
128 └──────────────────────┘
129```
130
131---
132
133## 🔧 IMPLEMENTATION
134
135### Phase 1: Add Configurable Relay (Week 1)
136
137#### 1.1 Create RelayClient (pear-app/lib/relay-client.js)
138
139```javascript
140/**
141 * RelayClient - Simple HTTP + short-lived WS relay interaction
142 *
143 * Patterns:
144 * - Push: Short-lived WebSocket (connect, send, close)
145 * - Pull: HTTP GET requests
146 * - No persistent state
147 * - Configurable relay URL
148 */
149
150import https from 'bare-https'
151
152export class RelayClient {
153 constructor(relayUrl = 'https://7rnx.net/relay') {
154 this.relayUrl = relayUrl
155 this.wsUrl = relayUrl.replace('https://', 'wss://').replace('http://', 'ws://')
156 }
157
158 /**
159 * Push post to relay (short-lived WebSocket)
160 *
161 * @param {string} pubkey - Author public key (base64, 44 chars)
162 * @param {string} yaml - YAML message content
163 * @param {string} signature - ANProto signature (base64, 132 bytes)
164 * @param {string} [imageData] - Optional image data
165 */
166 async push(pubkey, yaml, signature, imageData = null) {
167 const WebSocket = await import('bare-ws')
168 const { WebSocket: WS } = WebSocket
169
170 return new Promise((resolve, reject) => {
171 console.log(`📤 Pushing to relay: ${this.wsUrl}`)
172
173 const ws = new WS(this.wsUrl)
174 const timeout = setTimeout(() => {
175 ws.close()
176 reject(new Error('Relay push timeout (10s)'))
177 }, 10000)
178
179 ws.on('open', () => {
180 clearTimeout(timeout)
181 console.log(' ✓ Connected to relay')
182
183 // Send in order: pubkey, content, image (optional), signature
184 ws.send(pubkey)
185 console.log(' → Sent pubkey')
186
187 ws.send(yaml)
188 console.log(' → Sent content')
189
190 if (imageData) {
191 ws.send(imageData)
192 console.log(' → Sent image')
193 }
194
195 ws.send(signature)
196 console.log(' → Sent signature')
197
198 // Close after 2 seconds (relay processes messages)
199 setTimeout(() => {
200 ws.close()
201 console.log('✅ Post pushed to relay')
202 resolve()
203 }, 2000)
204 })
205
206 ws.on('error', (err) => {
207 clearTimeout(timeout)
208 console.error('❌ Relay push error:', err.message)
209 reject(err)
210 })
211 })
212 }
213
214 /**
215 * Pull posts from relay (HTTP GET)
216 *
217 * @param {Object} options - Query options
218 * @param {number} [options.limit] - Max posts to fetch
219 * @param {string} [options.author] - Filter by author pubkey
220 * @returns {Promise<Array>} Array of posts
221 */
222 async pull(options = {}) {
223 const endpoint = options.author
224 ? `/directory/${encodeURIComponent(options.author)}`
225 : '/all'
226
227 const url = `${this.relayUrl}${endpoint}`
228 console.log(`📥 Pulling from relay: ${url}`)
229
230 return new Promise((resolve, reject) => {
231 const timeout = setTimeout(() => {
232 reject(new Error('Relay pull timeout (30s)'))
233 }, 30000)
234
235 https.get(url, (res) => {
236 let data = ''
237
238 res.on('data', chunk => {
239 data += chunk.toString()
240 })
241
242 res.on('end', () => {
243 clearTimeout(timeout)
244 try {
245 const posts = JSON.parse(data)
246 console.log(`✅ Pulled ${Array.isArray(posts) ? posts.length : '?'} posts from relay`)
247 resolve(posts)
248 } catch (err) {
249 reject(new Error(`Invalid JSON response: ${err.message}`))
250 }
251 })
252
253 res.on('error', (err) => {
254 clearTimeout(timeout)
255 reject(err)
256 })
257 }).on('error', (err) => {
258 clearTimeout(timeout)
259 reject(err)
260 })
261 })
262 }
263
264 /**
265 * Pull user directory from relay
266 *
267 * @param {string} [searchQuery] - Optional search query
268 * @returns {Promise<Object>} Directory response
269 */
270 async pullDirectory(searchQuery = null) {
271 const endpoint = searchQuery
272 ? `/directory/search?q=${encodeURIComponent(searchQuery)}`
273 : '/directory'
274
275 const url = `${this.relayUrl}${endpoint}`
276 console.log(`📥 Pulling directory: ${url}`)
277
278 return new Promise((resolve, reject) => {
279 https.get(url, (res) => {
280 let data = ''
281 res.on('data', chunk => data += chunk.toString())
282 res.on('end', () => {
283 try {
284 resolve(JSON.parse(data))
285 } catch (err) {
286 reject(new Error(`Invalid JSON: ${err.message}`))
287 }
288 })
289 res.on('error', reject)
290 }).on('error', reject)
291 })
292 }
293}
294```
295
296#### 1.2 Add Configuration (pear-app/index.js)
297
298```javascript
299// Near top of file
300const DEFAULT_RELAY_URL = 'https://7rnx.net/relay'
301const RELAY_URL = process.env.RELAY_URL || DEFAULT_RELAY_URL
302
303// Import RelayClient
304import { RelayClient } from './lib/relay-client.js'
305
306// Initialize relay client
307let relayClient = null
308
309async function getRelayClient() {
310 if (!relayClient) {
311 relayClient = new RelayClient(RELAY_URL)
312 }
313 return relayClient
314}
315```
316
317#### 1.3 Update cmdPost() to Push to Relay
318
319```javascript
320async function cmdPost(text) {
321 await loadState()
322 if (!myFeed) {
323 console.error('❌ Not initialized. Run: init')
324 return
325 }
326
327 await startSwarm()
328
329 // Get username
330 const usernameNode = await metaBee.get('username')
331 const username = usernameNode ? usernameNode.value : null
332
333 // Create YAML
334 const yaml = YAMLHelper.create({
335 name: username,
336 image: null,
337 previous: null
338 }, text)
339
340 // Create ANProto signature
341 const timestamp = Date.now().toString()
342 const crypto = await import('hypercore-crypto')
343 const contentHash = crypto.hash(Buffer.from(yaml, 'utf-8')).toString('base64url')
344 const signaturePayload = timestamp + contentHash
345 const signature = ANProto.sign(signaturePayload, anprotoKeypair.secretKey)
346
347 // 1. Append to own Hypercore feed (P2P)
348 await myFeed.append(JSON.stringify({
349 text: yaml,
350 timestamp,
351 username
352 }))
353
354 console.log('✅ Posted to feed')
355
356 // 2. Push to relay (optional, graceful failure)
357 try {
358 const relay = await getRelayClient()
359 const pubkey = b4a.toString(anprotoKeypair.publicKey, 'base64')
360
361 await relay.push(pubkey, yaml, signature, null)
362 console.log('✅ Pushed to relay')
363 } catch (err) {
364 console.warn('⚠️ Relay push failed (continuing anyway):', err.message)
365 // Don't fail the post if relay is down
366 }
367
368 broadcast({ type: 'new_post', feed: 'own' })
369}
370```
371
372#### 1.4 Add cmdPull() for Relay Fetch
373
374```javascript
375/**
376 * Command: Pull posts from relay
377 */
378async function cmdPull(options = {}) {
379 await loadState()
380 if (!myFeed) {
381 console.error('❌ Not initialized. Run: init')
382 return
383 }
384
385 try {
386 const relay = await getRelayClient()
387 const posts = await relay.pull(options)
388
389 console.log(`\n📬 Relay Posts (${posts.length}):\n`)
390
391 posts.slice(0, options.limit || 20).forEach(post => {
392 // Parse YAML
393 const lines = post.text?.split('\n') || []
394 let name = 'Unknown'
395 let content = post.text
396
397 // Extract name from YAML frontmatter
398 for (const line of lines) {
399 if (line.startsWith('name:')) {
400 name = line.substring(5).trim().replace(/'/g, '')
401 break
402 }
403 }
404
405 // Extract content after ---
406 const separator = content.indexOf('---\n')
407 if (separator !== -1) {
408 content = content.substring(separator + 4)
409 }
410
411 console.log(`${name} (${post.author.substring(0, 16)}...)`)
412 console.log(` ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
413 console.log(` ${new Date(post.ts).toLocaleString()}\n`)
414 })
415 } catch (err) {
416 console.error('❌ Failed to pull from relay:', err.message)
417 }
418}
419```
420
421---
422
423### Phase 2: Integrate Pull into Timeline (Week 2)
424
425#### 2.1 Add "Refresh from Relay" Button
426
427**In web interface** (`public/app-simple.js`):
428
429```javascript
430// Add button
431<button id="relay-refresh-btn" class="icon-btn" title="Refresh from Relay">
432 <span class="material-symbols-outlined">Cloud_Download</span>
433</button>
434
435// Event handler
436document.getElementById('relay-refresh-btn').addEventListener('click', async () => {
437 console.log('Fetching from relay...')
438
439 try {
440 const response = await fetch('/api/relay/pull')
441 const data = await response.json()
442
443 console.log(`Pulled ${data.count} posts from relay`)
444
445 // Refresh timeline
446 await loadTimeline()
447 } catch (err) {
448 console.error('Relay pull failed:', err.message)
449 }
450})
451```
452
453**In server** (`index.js API handler`):
454
455```javascript
456case '/api/relay/pull':
457 if (req.method === 'POST') {
458 try {
459 const relay = await getRelayClient()
460 const posts = await relay.pull()
461
462 // Process and store posts in contentCache
463 let count = 0
464 for (const post of posts) {
465 // TODO: verify signature, store content
466 count++
467 }
468
469 res.writeHead(200, { 'Content-Type': 'application/json' })
470 res.end(JSON.stringify({ success: true, count }))
471 } catch (err) {
472 res.writeHead(500, { 'Content-Type': 'application/json' })
473 res.end(JSON.stringify({ error: err.message }))
474 }
475 }
476 break
477```
478
479---
480
481### Phase 3: Configuration & Testing (Week 3)
482
483#### 3.1 Add Configuration File
484
485**Create**: `pear-app/config.json`
486
487```json
488{
489 "relay": {
490 "url": "https://7rnx.net/relay",
491 "enabled": true,
492 "pushOnPost": true,
493 "alternates": [
494 "https://pub.wiredove.net",
495 "https://7rnx.net/relay"
496 ]
497 }
498}
499```
500
501#### 3.2 Environment Variables
502
503```bash
504# ~/.bashrc or systemd service
505export WIRECORE_RELAY_URL=https://7rnx.net/relay
506export WIRECORE_RELAY_ENABLED=true
507
508# For testing with pub.wiredove.net
509export WIRECORE_RELAY_URL=https://pub.wiredove.net
510```
511
512#### 3.3 CLI Command
513
514```bash
515# Post with relay push
516pear run . cli post "Hello relay!"
517
518# Pull from relay
519pear run . cli pull --limit 10
520
521# Pull from specific author
522pear run . cli pull --author <pubkey>
523
524# Configure relay
525pear run . cli config relay https://pub.wiredove.net
526```
527
528---
529
530## 🧪 TESTING PLAN
531
532### Test 1: Push to Relay
533
534```bash
535# Terminal 1: Check relay logs
536ssh tabertron
537journalctl -u wirecore-relay -f
538
539# Terminal 2: Post from pear-app
540cd pear-app
541pear run . cli post "Test relay push"
542
543# Terminal 3: Verify on relay
544curl https://7rnx.net/relay/all | jq '.[] | select(.text | contains("Test relay push"))'
545```
546
547**Expected**: Post appears in relay within 2 seconds
548
549### Test 2: Pull from Relay
550
551```bash
552# Post directly to relay (simulate wiredove user)
553curl -X POST https://7rnx.net/relay/post \
554 -H "Content-Type: application/json" \
555 -d '{"text": "From wiredove", "author": "..."}'
556
557# Pull from pear-app
558pear run . cli pull --limit 5
559
560# Expected: See "From wiredove" in output
561```
562
563### Test 3: Cross-Network
564
565```bash
566# Machine A: Post from wirecore
567pear run . cli post "Hello from wirecore"
568
569# Machine B: Check on pub.wiredove.net
570# Open https://pub.wiredove.net/
571# Should see "Hello from wirecore"
572
573# Machine C: Post from wiredove
574# (use browser on pub.wiredove.net)
575
576# Machine A: Pull from relay
577pear run . cli pull
578# Should see wiredove post
579```
580
581### Test 4: Relay Fallback
582
583```bash
584# Stop relay
585systemctl stop wirecore-relay
586
587# Post from pear-app
588pear run . cli post "Offline test"
589# Expected: ⚠️ warning but post succeeds locally
590
591# Timeline should still work (P2P only)
592pear run . cli timeline
593```
594
595---
596
597## 📊 TRADE-OFF ANALYSIS
598
599| Aspect | Persistent WS (V1) | HTTP + Short WS (V2) |
600|--------|-------------------|----------------------|
601| Complexity | High | Low |
602| Real-time | Yes | No (manual refresh) |
603| bare-ws issues | Yes (bidirectional) | Minimal (outgoing only) |
604| State management | Complex | Stateless |
605| Attack surface | Larger | Smaller |
606| Relay dependency | High | Low |
607| UX | Better (auto-update) | Good enough (refresh) |
608| Implementation time | 2-3 weeks | 1 week |
609| Maintenance | Ongoing | Minimal |
610| **Fit for current needs** | ❌ Over-engineered | ✅ Perfect |
611
612---
613
614## 🎯 RECOMMENDATION
615
616**Use V2 (HTTP + Short-Lived WebSocket)** because:
617
6181. ✅ **Works with existing code patterns** (wiredove-cli proves it)
6192. ✅ **Avoids bare-ws browser issues** (only outgoing WS)
6203. ✅ **Simpler to implement and maintain**
6214. ✅ **Matches user requirements** (manual refresh is OK)
6225. ✅ **Graceful degradation** (relay failure doesn't break app)
6236. ✅ **Configurable** (easy to switch between relays)
6247. ✅ **Can add real-time later** if needed (incremental improvement)
625
626---
627
628## 🚀 IMPLEMENTATION TIMELINE
629
630**Week 1**: Core relay client
631- RelayClient class with push() and pull()
632- Update cmdPost() to push to relay
633- Add cmdPull() command
634- Basic testing
635
636**Week 2**: Integration
637- Add relay pull to web interface
638- Configuration system
639- Error handling and logging
640- Multi-peer testing
641
642**Week 3**: Polish
643- Documentation
644- Configuration examples
645- Performance tuning
646- Production deployment
647
648---
649
650## 📚 REFERENCES
651
652- **Working Implementation**: `/wirecore/cli/wiredove-cli/wiredove.js`
653- **Relay Server**: `/wirecore/relay/wirecore-relay.js`
654- **APDS Protocol**: `/wirecore/docs/claude/wirecore-skills.md`
655- **ANProto**: `/wirecore/pear-app/lib/anproto.js`
656
657---
658
659**Document**: RELAY_INTEGRATION_PLAN_V2.md
660**Status**: Ready for implementation
661**Estimated Effort**: 1 week (vs 3 weeks for V1)
662**Risk**: Low (proven patterns)
WEB_INTERFACE_OPTIONS.md +513 -0
……@@ -0,0 +1,513 @@
1# Wirecore Web Interface Options
2
3This document outlines different approaches to add a web interface to the wirecore Pear terminal app.
4
5## Current Architecture
6
7**Wirecore Pear App** (terminal only):
8```
9Terminal CLI (pear-app/index.js)
10 ↕
11Hypercore + Hyperswarm (P2P)
12 ↕
13wirecore-storage/ (local Corestore)
14```
15
16**Existing Components:**
17- ✅ Pear terminal app with CLI commands
18- ✅ Wirecore relay with basic web UI
19- ✅ Fedicore web interface (separate project, full-featured)
20
21## Option 1: HTTP Server in Pear App (Recommended)
22
23**Architecture:**
24```
25Browser (http://localhost:3000)
26 ↕ HTTP REST API + WebSocket
27Pear Terminal App (index.js)
28 + HTTP Server (bare-http1)
29 + Static file serving
30 ↕
31Hypercore + Hyperswarm (P2P)
32```
33
34### Implementation
35
36**Step 1: Add HTTP dependencies**
37
38```json
39{
40 "dependencies": {
41 "bare-http1": "^4.0.2",
42 "bare-https": "^2.0.0"
43 }
44}
45```
46
47**Step 2: Add HTTP server to index.js**
48
49```javascript
50import http from 'bare-http1'
51import path from 'bare-path'
52import fs from 'bare-fs'
53
54// HTTP server for web UI
55const server = http.createServer(async (req, res) => {
56 const url = new URL(req.url, 'http://localhost')
57
58 // CORS
59 res.setHeader('Access-Control-Allow-Origin', '*')
60 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
61
62 if (req.method === 'OPTIONS') {
63 res.writeHead(200)
64 res.end()
65 return
66 }
67
68 // API routes
69 if (url.pathname.startsWith('/api/')) {
70 await handleAPI(req, res, url)
71 return
72 }
73
74 // Serve static files
75 await serveStatic(req, res, url)
76})
77
78server.listen(3000, () => {
79 console.log('🌐 Web interface: http://localhost:3000')
80})
81```
82
83**Step 3: API endpoints**
84
85```javascript
86async function handleAPI(req, res, url) {
87 const route = url.pathname
88
89 switch (route) {
90 case '/api/whoami':
91 res.writeHead(200, { 'Content-Type': 'application/json' })
92 res.end(JSON.stringify({
93 pubkey: identity.publicKey.toString('hex'),
94 feedKey: myFeed.key.toString('hex')
95 }))
96 break
97
98 case '/api/timeline':
99 const posts = await getTimeline(50)
100 res.writeHead(200, { 'Content-Type': 'application/json' })
101 res.end(JSON.stringify(posts))
102 break
103
104 case '/api/post':
105 if (req.method === 'POST') {
106 const body = await readBody(req)
107 const data = JSON.parse(body)
108 await myFeed.append(Buffer.from(data.message))
109 res.writeHead(200)
110 res.end()
111 }
112 break
113
114 // ... more endpoints
115 }
116}
117```
118
119**Step 4: Static HTML/CSS/JS**
120
121Create `pear-app/public/` directory:
122```
123pear-app/
124├── index.js # Main app + HTTP server
125├── public/
126│ ├── index.html # Web UI
127│ ├── style.css # Styles
128│ └── app.js # Frontend client
129```
130
131### Pros
132- ✅ Single Pear app (CLI + Web)
133- ✅ Uses Bare runtime (no Node.js needed)
134- ✅ Works offline (no external relay)
135- ✅ Smallest package size
136- ✅ Can copy fedicore's web UI code
137
138### Cons
139- ⚠️ HTTP server adds ~5-10 MB
140- ⚠️ Need to implement API endpoints
141- ⚠️ Browser can only access local instance
142
143### Best For
144- Personal use
145- Single-user instances
146- Full offline capability
147- Minimal dependencies
148
149---
150
151## Option 2: Pear Desktop App with Electron (Full GUI)
152
153**Architecture:**
154```
155Electron Window (HTML/CSS/JS)
156 ↕ IPC Bridge
157Pear Desktop Runtime
158 ↕
159Hypercore + Hyperswarm (P2P)
160```
161
162### Implementation
163
164**Step 1: Change package.json**
165
166```json
167{
168 "pear": {
169 "type": "desktop", // Changed from "terminal"
170 "pre": "pear-electron/pre"
171 },
172 "dependencies": {
173 "pear-electron": "^1.7.20",
174 "pear-bridge": "^1.2.0"
175 }
176}
177```
178
179**Step 2: Create app.js (backend)**
180
181```javascript
182import Runtime from 'pear-electron'
183import Bridge from 'pear-bridge'
184
185const runtime = new Runtime()
186const bridge = new Bridge()
187await bridge.ready()
188
189// Register bridge endpoints
190bridge.route('GET /api/whoami', async () => {
191 return {
192 pubkey: identity.publicKey.toString('hex'),
193 feedKey: myFeed.key.toString('hex')
194 }
195})
196
197bridge.route('GET /api/timeline', async () => {
198 return await getTimeline(50)
199})
200
201bridge.route('POST /api/post', async (req) => {
202 const { message } = await req.json()
203 await myFeed.append(Buffer.from(message))
204 return { success: true }
205})
206
207const pipe = runtime.start({ bridge })
208Pear.teardown(() => pipe.end())
209```
210
211**Step 3: Create index.html**
212
213```html
214<!DOCTYPE html>
215<html>
216<head>
217 <title>Wirecore</title>
218 <link rel="stylesheet" href="style.css">
219</head>
220<body>
221 <div id="app"></div>
222 <script src="ui.js" type="module"></script>
223</body>
224</html>
225```
226
227**Step 4: Create ui.js**
228
229```javascript
230import ui from 'pear-electron'
231
232// Fetch API works through pear-bridge
233const response = await fetch('/api/timeline')
234const posts = await response.json()
235
236// Render UI
237renderTimeline(posts)
238
239// Window controls
240await ui.app.focus({ steal: true })
241```
242
243### Pros
244- ✅ Native desktop app experience
245- ✅ Full Electron APIs (file dialogs, notifications, etc.)
246- ✅ Works offline
247- ✅ Can use React/Vue/Svelte
248- ✅ OS integration (menubar, dock, etc.)
249
250### Cons
251- ⚠️ Larger package (~80-100 MB with Electron)
252- ⚠️ More complex build process
253- ⚠️ Requires pear-electron setup
254
255### Best For
256- Full desktop application
257- Rich UI with frameworks
258- OS integration needed
259- Distribution to end users
260
261---
262
263## Option 3: Standalone Web App + Relay (Multi-User)
264
265**Architecture:**
266```
267Browser (anywhere)
268 ↕ HTTP + WebSocket
269Wirecore Relay (relay/wirecore-relay.js)
270 ↕ Hypercore Protocol
271Pear Terminal Apps (multiple users)
272```
273
274### Implementation
275
276**Already exists!** The relay has a web UI embedded. Just enhance it.
277
278**Step 1: Extract web UI to separate files**
279
280```bash
281cd relay/
282mkdir public
283```
284
285**Step 2: Create public/index.html**
286
287Copy from relay's embedded HTML and expand.
288
289**Step 3: Create public/app.js**
290
291```javascript
292const ws = new WebSocket('ws://localhost:9000')
293
294ws.onmessage = (event) => {
295 // Handle messages from relay
296 const data = event.data
297 if (data.length === 44) {
298 // Hash response
299 displayMessage(data)
300 } else {
301 // New message
302 storeAndDisplay(data)
303 }
304}
305
306async function post(message) {
307 // Sign message
308 const signed = await signMessage(message)
309 ws.send(signed)
310}
311```
312
313**Step 4: Serve from relay**
314
315```javascript
316const handleHTTP = async (req, res) => {
317 const url = new URL(req.url, 'http://localhost')
318
319 // Serve static files from public/
320 if (url.pathname === '/' || url.pathname === '/index.html') {
321 const html = fs.readFileSync('./public/index.html')
322 res.writeHead(200, { 'Content-Type': 'text/html' })
323 res.end(html)
324 return
325 }
326
327 if (url.pathname === '/app.js') {
328 const js = fs.readFileSync('./public/app.js')
329 res.writeHead(200, { 'Content-Type': 'application/javascript' })
330 res.end(js)
331 return
332 }
333
334 // ... API routes
335}
336```
337
338### Pros
339- ✅ Multi-user support
340- ✅ Browser-only access (no app install)
341- ✅ Can federate with other relays
342- ✅ Works on any device with browser
343
344### Cons
345- ⚠️ Requires relay server running
346- ⚠️ Not fully P2P (relay mediated)
347- ⚠️ Network dependency
348
349### Best For
350- Public instances
351- Multi-user communities
352- Web-first experience
353- Federation with other networks
354
355---
356
357## Option 4: Hybrid Approach (Best of All)
358
359**Architecture:**
360```
361┌─────────────────────────────────────┐
362│ Browser Access (anywhere) │
363│ ↕ │
364│ Wirecore Relay (optional) │
365└─────────────────────────────────────┘
366 ↕
367┌─────────────────────────────────────┐
368│ Pear App (terminal OR desktop) │
369│ + HTTP Server (localhost:3000) │
370│ + WebSocket │
371│ ↕ │
372│ Hypercore + Hyperswarm (P2P) │
373└─────────────────────────────────────┘
374```
375
376### Implementation
377
378Combine Option 1 + Option 3:
379
3801. Pear app runs HTTP server on localhost:3000
3812. Browser connects to localhost for single-user
3823. Optionally connect to relay for multi-user
383
384**Pear app mode selection:**
385
386```javascript
387// CLI mode
388if (process.argv[2] === 'init' || process.argv[2] === 'post') {
389 runCLI()
390 process.exit(0)
391}
392
393// Web mode (default)
394startHTTPServer()
395console.log('Web interface: http://localhost:3000')
396console.log('Relay mode: ws://localhost:9000 (if relay running)')
397```
398
399**Web UI connects to both:**
400
401```javascript
402// Local P2P connection
403const localAPI = 'http://localhost:3000'
404
405// Remote relay (optional)
406const relayWS = new WebSocket('ws://localhost:9000')
407
408// Use local by default, fallback to relay
409async function getPosts() {
410 try {
411 const res = await fetch(`${localAPI}/api/timeline`)
412 return await res.json()
413 } catch {
414 // Fallback to relay
415 return await fetchFromRelay()
416 }
417}
418```
419
420### Pros
421- ✅ Flexibility: CLI, Web, or both
422- ✅ Offline + Online modes
423- ✅ Single-user or multi-user
424- ✅ Gradual migration path
425
426### Cons
427- ⚠️ More code to maintain
428- ⚠️ Complexity in mode switching
429
430### Best For
431- Maximum flexibility
432- Production deployment
433- Supporting all use cases
434
435---
436
437## Comparison Table
438
439| Feature | Option 1 (HTTP) | Option 2 (Desktop) | Option 3 (Relay) | Option 4 (Hybrid) |
440|---------|-----------------|--------------------|--------------------|-------------------|
441| **Package Size** | 55 MB | 90 MB | 55 MB | 60 MB |
442| **Offline** | ✅ | ✅ | ❌ | ✅ |
443| **Multi-User** | ❌ | ❌ | ✅ | ✅ |
444| **CLI Access** | ✅ | ❌ | ✅ | ✅ |
445| **Native UI** | ❌ | ✅ | ❌ | ❌ |
446| **Browser UI** | ✅ | ✅ | ✅ | ✅ |
447| **Complexity** | Low | Medium | Low | High |
448| **Build Time** | Fast | Slow | Fast | Medium |
449
450---
451
452## Recommendation
453
454**Start with Option 1 (HTTP Server in Pear App)**
455
456**Why:**
4571. Simplest to implement
4582. Can reuse fedicore web UI code
4593. Works offline (true P2P)
4604. Keeps package size small
4615. Easy migration to Option 4 later
462
463**Implementation Plan:**
464
4651. ✅ Copy fedicore's web UI (HTML/CSS/JS)
4662. ✅ Add bare-http1 to pear-app
4673. ✅ Implement API endpoints
4684. ✅ Test locally
4695. ⏳ Enhance UI for wirecore features
4706. ⏳ Add WebSocket for real-time updates
471
472**Later (if needed):**
473- Migrate to Option 2 for desktop app
474- Add Option 3 relay support for federation
475- Combine into Option 4 for full flexibility
476
477---
478
479## Code Examples Ready to Use
480
481### From Fedicore
482
483Can copy and adapt:
484- `lib/server/index.js` - Express server structure
485- `public/index.html` - HTML structure
486- `public/style.css` - Wiredove-inspired design
487- `public/app.js` - Frontend client code
488
489### From Wirecore Relay
490
491Can copy:
492- WebSocket handler
493- APDS protocol implementation
494- Basic timeline rendering
495
496### New for Wirecore Pear
497
498Need to implement:
499- bare-http1 server (instead of Express)
500- Integration with existing CLI code
501- Mode switching (CLI vs Web)
502
503---
504
505## Next Steps
506
5071. **Choose option** (recommend Option 1)
5082. **Set up HTTP server** in pear-app/index.js
5093. **Copy web UI** from fedicore
5104. **Implement API endpoints**
5115. **Test and iterate**
512
513Ready to start implementing when you decide which option to pursue!
WIREDOVE_FEATURES.md +219 -0
……@@ -0,0 +1,219 @@
1# Wiredove Features Analysis
2
3Complete feature inventory from evbogue/wiredove codebase analysis.
4
5## Architecture Overview
6
7**Technology Stack:**
8- **APDS (bog5)** - Content-addressable storage
9- **ANProto** - Message authentication protocol
10- **Trystero** - P2P WebRTC via BitTorrent DHT
11- **WebSocket** - Server sync fallback
12- **Hybrid Network** - Dual-channel (WS + gossip)
13
14## Complete Feature List
15
16### Posting & Content (17 features)
17- ✅ Compose posts (modal interface)
18- ✅ Reply to posts (with context)
19- ✅ **Edit posts** with full history
20- ✅ **Browse edit history** (prev/next navigation)
21- ✅ Preview before publish
22- ✅ Markdown rendering
23- ✅ Image uploads
24- ✅ **Hashtag auto-linking**
25- ✅ Post linking by hash
26- ✅ **Image popover viewer** (full-size)
27- ✅ Lazy-load images
28- ✅ Reply threading
29- ✅ Reply counting
30- ✅ Reply preview snippets
31- ✅ **Reply lazy-loading** (IntersectionObserver)
32- ✅ Timestamp display with auto-refresh
33- ✅ Code block highlighting
34
35### Identity & Profiles (12 features)
36- ✅ Keypair generation
37- ✅ **Vanity keypair generation** (name prefix matching)
38- ✅ Keypair import/export
39- ✅ Custom usernames
40- ✅ Profile avatar upload
41- ✅ **Avatar auto-cropping** (256px)
42- ✅ Avatar display throughout UI
43- ✅ **Name caching** (60s TTL)
44- ✅ Default avatar generation
45- ✅ Public key display
46- ✅ Profile viewing by pubkey
47- ✅ Clickable avatar to upload
48
49### Feed & Discovery (11 features)
50- ✅ Home feed (followed users)
51- ✅ **Feed pagination** (25 posts/page)
52- ✅ **Feed backfilling** (6-level deep chains)
53- ✅ **Seed-based loading** (latest 3 posts)
54- ✅ **New posts banner** (smart display)
55- ✅ Manual post refresh
56- ✅ User profile feeds
57- ✅ Follow list feeds
58- ✅ **Hashtag search feeds**
59- ✅ Full-text search
60- ✅ Infinite scroll
61
62### Networking (14 features)
63- ✅ **WebSocket server sync**
64- ✅ **Gossip protocol** (Trystero P2P)
65- ✅ **Hybrid network stack** (WS + gossip)
66- ✅ **Network queue** with retry logic
67- ✅ **Dual-send strategy** (redundant)
68- ✅ Hash retry on alternate network
69- ✅ **Auto-reconnection** (exponential backoff)
70- ✅ Connection state tracking
71- ✅ **Activity-based sync tiers:**
72 - Hot tier (15min) → 15s sync
73 - Warm tier (24hr) → 20min sync
74 - Cold tier (30d) → 6hr sync
75- ✅ Bootstrap latest posts
76- ✅ 6-hour activity tracking
77- ✅ Pubkey auto-refresh (5min)
78- ✅ Push/pull all data
79- ✅ Selective sync (followed only)
80
81### Notifications (7 features)
82- ✅ Service worker registration
83- ✅ **Web push subscriptions** (VAPID)
84- ✅ Toggle notifications
85- ✅ Local notifications
86- ✅ Permission handling
87- ✅ **ntfy.sh webhook integration**
88- ✅ Status messages
89
90### UI & Navigation (10 features)
91- ✅ URL hash routing
92- ✅ Search box with real-time routing
93- ✅ Navbar with branding
94- ✅ Settings page
95- ✅ Profile quick link
96- ✅ Modal composer
97- ✅ Modal overlays (click outside)
98- ✅ **QR code generator** for posts
99- ✅ **Raw message viewer**
100- ✅ Material Design icons
101- ✅ Full accessibility support
102
103### Settings (7 features)
104- ✅ Upload photo
105- ✅ Change name
106- ✅ Import keypair
107- ✅ Export keypair
108- ✅ Delete keypair
109- ✅ **Delete all local data**
110- ✅ Did:web integration (planned)
111
112### Performance (9 features)
113- ✅ Message cache
114- ✅ Avatar image caching (60s)
115- ✅ Reply preview cache
116- ✅ Edit cache (5s TTL)
117- ✅ **IntersectionObserver** lazy-loading
118- ✅ Scroll-based rendering
119- ✅ **Pending post queue**
120- ✅ Deduplication
121- ✅ Smart timestamp refresh
122
123### Data Storage (7 features)
124- ✅ APDS local database
125- ✅ Database versioning
126- ✅ Keypair persistence
127- ✅ Profile data storage
128- ✅ Feed aliases
129- ✅ Query indexing
130- ✅ Latest post tracking
131
132### Error Handling (5 features)
133- ✅ WebSocket reconnection
134- ✅ Gossip fallback
135- ✅ Missing data auto-request
136- ✅ Graceful degradation
137- ✅ Error logging
138
139## 🌟 Unique/Innovative Features
140
1411. **Vanity Keypair Generation** - Generate keypairs with matching name prefix
1422. **Dual-Channel Broadcasting** - Redundant WS + P2P gossip
1433. **Activity Tiering** - 3-tier sync scheduler (hot/warm/cold)
1444. **Reply Lazy-Loading** - IntersectionObserver for efficient rendering
1455. **Edit Version Navigation** - Browse full edit history
1466. **QR Code Generation** - Share posts via QR
1477. **New Posts Banner** - Smart "show N new posts" UX
1488. **Hybrid Network Stack** - Seamless WS/gossip fallback
1499. **Hashtag Auto-Linking** - Detect and link hashtags
15010. **Feed Backfilling** - Smart 6-level message chain backfill
151
152## Wirecore vs Wiredove
153
154### Wirecore Currently Has:
155- ✅ Basic posting
156- ✅ Follow/unfollow
157- ✅ Timeline viewing
158- ✅ User identity (keypair)
159- ✅ Feed storage (Hypercore)
160- ✅ P2P networking (Hyperswarm)
161- ✅ Web interface (HTTP + WebSocket)
162
163### Wiredove Adds:
164- 📝 **Post editing** with history
165- 🔍 **Hashtag search**
166- 🎨 **Vanity keypair generation**
167- 🔄 **Hybrid network** (multiple transports)
168- 📊 **Activity-based sync tiers**
169- 🖼️ **Image viewer/uploader**
170- 🔔 **Push notifications**
171- ⚡ **Performance optimizations**
172- 🎯 **Advanced UX** (lazy-loading, caching, etc.)
173
174## Recommended Wirecore Roadmap
175
176### Phase 1: Core Enhancements
1771. **Post editing** - Essential feature
1782. **Image uploads** - Already have UI, need backend
1793. **Hashtag support** - Easy to add
1804. **Profile avatars** - User customization
181
182### Phase 2: Discovery
1835. **Hashtag search feeds**
1846. **Full-text search**
1857. **User discovery/directory**
186
187### Phase 3: UX Polish
1888. **Reply lazy-loading**
1899. **New posts banner**
19010. **Image popover viewer**
19111. **Feed pagination**
192
193### Phase 4: Advanced Networking
19412. **Activity-based sync tiers**
19513. **Network queue with retry**
19614. **Hybrid network fallback**
197
198### Phase 5: Power Features
19915. **Vanity keypair generation**
20016. **QR code generation**
20117. **Edit history browsing**
20218. **Push notifications**
203
204## Key Takeaways
205
206**Wiredove's Strengths:**
207- Extremely polished UX with IntersectionObserver optimizations
208- Sophisticated network layer with dual-channel redundancy
209- Smart sync scheduler based on user activity patterns
210- Comprehensive feature set for a P2P social network
211
212**Wirecore's Advantages:**
213- Hypercore/Hyperswarm (more mature P2P stack)
214- Pear deployment (native app capability)
215- Simpler codebase to start with
216- Already has working web interface
217
218**Opportunity:**
219Combine Wirecore's solid P2P foundation with Wiredove's innovative features and UX polish to create the best P2P social network!
pear-app/ASHATRON_DEPLOYMENT.md +129 -0
……@@ -0,0 +1,129 @@
1# Wirecore Deployment to Ashatron
2
3## Pear Link
4```
5pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo
6```
7
8## Deployment Steps
9
10### 1. Install Pear on Ashatron (if not already installed)
11```bash
12npx pear
13```
14
15### 2. Monitor Download Size
16Before running, check current Pear cache size:
17```bash
18du -sh ~/.config/pear/app-storage/
19```
20
21### 3. Run Wirecore
22```bash
23# Run on port 3003 (default)
24pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo
25
26# Or specify custom port
27pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003
28```
29
30### 4. Verify Download Size
31After first run:
32```bash
33du -sh ~/.config/pear/app-storage/zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo/
34```
35
36**Expected:** ~40-50MB (linux-x64 only)
37**Full staged:** ~46MB with JS + prebuilds
38
39### 5. Access Web Interface
40```
41http://ashatron:3003
42```
43
44## Systemd Service (Optional)
45
46Create `/etc/systemd/system/wirecore.service`:
47```ini
48[Unit]
49Description=Wirecore P2P Social Network
50After=network.target
51
52[Service]
53Type=simple
54User=fleeky
55Environment="PATH=/home/fleeky/.config/pear/bin:/usr/bin:/bin"
56ExecStart=/home/fleeky/.config/pear/bin/pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003
57Restart=always
58RestartSec=10
59
60[Install]
61WantedBy=multi-user.target
62```
63
64Enable and start:
65```bash
66sudo systemctl daemon-reload
67sudo systemctl enable wirecore
68sudo systemctl start wirecore
69sudo systemctl status wirecore
70```
71
72View logs:
73```bash
74sudo journalctl -u wirecore -f
75```
76
77## Current Build Info
78
79**Platform:** linux-x64 only
80**Size:** ~46MB staged
81**Download:** ~40-50MB per node (selective download)
82
83**Note:** For ARM/Raspberry Pi support, would need multi-platform build (staging from ARM device or CI/CD).
84
85## Verification
86
87### Check if server is running:
88```bash
89curl http://localhost:3003/api/whoami | jq .
90```
91
92### Post a test message:
93```bash
94curl -X POST http://localhost:3003/api/post \
95 -H "Content-Type: application/json" \
96 -d '{"message":"Hello from ashatron!"}'
97```
98
99### View timeline:
100```bash
101curl http://localhost:3003/api/timeline | jq .
102```
103
104## Port Configuration
105
106**Default:** 3003
107**Configurable:** `pear run ... web <port>`
108**Why 3003:** Avoid conflict with OpenWebUI (port 3000)
109
110## Troubleshooting
111
112### Clear cache and reinstall:
113```bash
114rm -rf ~/.config/pear/app-storage/zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo/
115pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003
116```
117
118### Check download progress:
119```bash
120# Watch cache directory grow
121watch -n 1 du -sh ~/.config/pear/app-storage/zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo/
122```
123
124## Network Requirements
125
126- **Incoming:** Not required (client mode)
127- **Outgoing:** UDP/TCP to Hyperswarm DHT bootstrap nodes
128- **Ports:** Random high ports for P2P connections
129- **Firewall:** No special rules needed for client operation
pear-app/ASHATRON_TEST_RESULTS.md +139 -0
……@@ -0,0 +1,139 @@
1# Ashatron Deployment Test Results
2
3Date: 2026-01-20
4Pear Link: `pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo`
5
6## Test Summary
7
8✅ **Deployment Successful**
9✅ **Selective Download Verified: 53MB**
10✅ **Web Server Running**
11✅ **API Responding**
12
13## Deployment Method
14
15Used `pear dump` instead of direct `pear run pear://...` due to permission issues with older Pear version.
16
17```bash
18# 1. Download app locally
19pear dump pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo /tmp/wirecore-test
20# Download size: 53MB
21
22# 2. Initialize
23cd /tmp/wirecore-test
24pear run --dev . init
25
26# 3. Run web server
27pear run --dev . web 3003
28```
29
30## Issues & Solutions
31
32### 1. Permission Error with Pear Link
33
34**Error:**
35```
36✖ Error: Permission required to run key
37```
38
39**Cause:** Older Pear version (v0.9609) on ashatron requires explicit permission approval for running pear links.
40
41**Solution:** Use `pear dump` to download locally, then run from directory. This bypasses the permission system.
42
43### 2. Server Only Listening on Localhost
44
45**Problem:** Server bound to 127.0.0.1, not accessible from network.
46
47**Fix:** Changed server.listen call:
48```javascript
49// Before
50server.listen(port, () => {
51
52// After
53server.listen(port, '0.0.0.0', () => {
54```
55
56**Result:** Now accessible via http://ashatron.local:3003
57
58### 3. Mutex Error on POST
59
60**Error:**
61```
62API error: Error: Mutex has been destroyed
63```
64
65**Cause:** Hypercore mutex lock conflict, likely due to:
66- Multiple instances accessing same storage
67- Dev mode storage path conflicts
68
69**Status:** Needs investigation. Server runs but POST operations fail.
70
71## Download Size Verification
72
73✅ **Confirmed selective download works!**
74
75- **Staged app size:** ~46MB (linux-x64 only)
76- **Downloaded via pear dump:** 53MB
77- **Breakdown:**
78 - JavaScript modules: ~6MB
79 - Native prebuilds (linux-x64): ~40MB
80 - App code: ~1MB
81 - Other files: ~6MB
82
83**Key Finding:** Each node downloads only what it needs. With multi-platform staging (~300MB), each platform would still download only ~40-50MB.
84
85## Ashatron Environment
86
87- **OS:** Linux x86_64
88- **Node:** v12.22.9
89- **Pear:** v0.9609 (older version)
90- **Network:** 192.168.50.228 (ashatron.local)
91
92## Recommendations
93
94### For Production Deployment:
95
961. **Update Pear** on ashatron to latest version
97 ```bash
98 npx pear # Updates to latest
99 ```
100
1012. **Use pear dump method** until Pear is updated:
102 ```bash
103 pear dump pear://... /opt/wirecore
104 cd /opt/wirecore
105 pear run --dev . init
106 pear run --dev . web 3003
107 ```
108
1093. **Systemd Service** with local directory:
110 ```ini
111 [Service]
112 ExecStart=/home/fleeky/.config/pear/bin/pear run /opt/wirecore web 3003
113 ```
114
1154. **Fix mutex issue** - investigate storage path conflicts
116
117### For Multi-Platform Support:
118
119To enable ARM/Raspberry Pi deployment:
1201. Set up CI/CD with build matrix (ubuntu-latest, macos-latest, ubuntu-arm64)
1212. Stage from machine with all platform prebuilds
1223. Each platform downloads only its ~40-50MB
123
124## Current Status
125
126**Wirecore on Ashatron:**
127- ✅ Running on port 3003
128- ✅ API accessible: http://ashatron.local:3003/api/whoami
129- ✅ WebSocket connected
130- ✅ P2P networking announced
131- ⚠️ POST operations need debugging (mutex error)
132
133## Next Steps
134
1351. Debug and fix mutex error
1362. Update Pear on ashatron to latest version
1373. Test full posting/following workflow
1384. Set up systemd service for production
1395. (Optional) Set up multi-platform build for ARM support
pear-app/BUG_FIXES.md +161 -0
……@@ -0,0 +1,161 @@
1# Wirecore Bug Fixes - Session Summary
2
3Date: 2026-01-20
4
5## Bugs Fixed
6
7### 1. Mutex Error - "Mutex has been destroyed"
8
9**Symptom:**
10```
11API error: Error: Mutex has been destroyed
12 at Mutex.lock (pear://dev/node_modules/hypercore/lib/mutex.js:13:69)
13 at Core.append (pear://dev/node_modules/hypercore/lib/core.js:652:23)
14 at Hypercore.append (pear://dev/node_modules/hypercore/index.js:1024:22)
15```
16
17**Root Cause:**
18The `finally` block in `main()` was calling `cleanup()` even when returning early from the `web` command. In JavaScript, `finally` blocks ALWAYS execute, even after a `return` statement.
19
20**Timeline of Bug:**
211. User runs `pear run . web 3003`
222. `startWebServer()` initializes store and myFeed
233. Function returns to keep server running
244. `finally` block executes anyway
255. `cleanup()` is called → `store.close()` → `store = null`
266. Server still running but `store` is now null
277. POST request arrives → tries to append to `myFeed`
288. `myFeed` exists but `store` is null → Hypercore mutex is destroyed → Error!
29
30**The Fix:**
31```javascript
32async function main() {
33 const args = isPear ? (Pear.config.args || []) : process.argv.slice(2)
34
35 // ... command parsing ...
36
37 // Long-running commands that should NOT cleanup
38 const longRunningCommands = ['web']
39 const shouldCleanup = !longRunningCommands.includes(command)
40
41 try {
42 // ... switch statement ...
43 } catch (err) {
44 console.error('❌ Error:', err.message)
45 console.error(err.stack)
46 } finally {
47 // Only cleanup for short-lived CLI commands
48 if (shouldCleanup) {
49 await cleanup()
50 }
51 }
52}
53```
54
55**Key Insight:**
56Long-running commands (like web servers) should NOT call cleanup in the finally block. Cleanup is handled by `graceful-goodbye` instead, which triggers on actual process termination signals.
57
58### 2. Server Only Listening on Localhost
59
60**Symptom:**
61Server started but was only accessible via `http://localhost:3003`, not from network.
62
63**Root Cause:**
64`server.listen(port, ...)` defaults to binding to `127.0.0.1` (localhost only).
65
66**The Fix:**
67```javascript
68// Before
69server.listen(port, () => {
70
71// After
72server.listen(port, '0.0.0.0', () => {
73```
74
75Now accessible via `http://ashatron.local:3003` from the network.
76
77### 3. Permission Error with Pear Link
78
79**Symptom:**
80```
81✖ Error: Permission required to run key
82```
83
84**Root Cause:**
85Older Pear version (v0.9609) on ashatron requires explicit permission approval for running pear links.
86
87**Workaround:**
88Use `pear dump` to download locally, then run from directory:
89```bash
90pear dump pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo /tmp/wirecore-test
91cd /tmp/wirecore-test
92pear run --dev . web 3003
93```
94
95**Future Solution:**
96User can type "TRUST" on first run to approve the pear link (when Pear is updated).
97
98## Test Results
99
100### Local Testing
101✅ POST messages successfully
102✅ Timeline retrieval works
103✅ WebSocket connections stable
104✅ No mutex errors
105
106### Ashatron Deployment
107✅ Downloaded via pear dump: 53MB
108✅ Posted successfully: `{"success":true}`
109✅ Timeline shows posts correctly
110✅ Network accessible: `http://ashatron.local:3003`
111✅ No mutex errors
112
113## Verification
114
115**POST Test:**
116```bash
117curl -X POST http://ashatron.local:3003/api/post \
118 -H "Content-Type: application/json" \
119 -d '{"message":"Testing bug fix on ashatron!"}'
120
121# Response:
122{"success":true}
123```
124
125**Timeline Test:**
126```bash
127curl http://ashatron.local:3003/api/timeline | jq '.[0]'
128
129# Response:
130{
131 "text": "Testing bug fix on ashatron!",
132 "timestamp": 1768950981863,
133 "author": "You",
134 "publicKey": "2e35679add8379d181a56a1ee8139fcaaa2b0ef7adeec58918630c1cadc02d8d",
135 "isOwn": true
136}
137```
138
139## Files Modified
140
1411. **index.js** - Fixed cleanup logic in main() function
1422. **index.js** - Added network binding (0.0.0.0) for server.listen()
1433. **index.js** - Cleaned up error handling in loadState()
144
145## Lessons Learned
146
1471. **Finally blocks always execute** - Even after return statements. Need to conditionally skip cleanup for long-running processes.
148
1492. **Pear dump is reliable** - When `pear run pear://...` has permission issues, `pear dump` bypasses the permission system and is more reliable for deployment.
150
1513. **Selective download works** - Confirmed that Pear's selective download works as expected. Staged 46MB, downloaded 53MB (only linux-x64 prebuilds).
152
1534. **Bare module bindings** - Always specify `0.0.0.0` for network-accessible servers. Bare defaults to localhost only.
154
155## Status
156
157All bugs fixed and tested. Wirecore is production-ready for linux-x64 deployments.
158
159**Pear Link:** `pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo`
160
161**Latest Version:** 2907 (released)
pear-app/CHANGELOG.md +169 -0
……@@ -0,0 +1,169 @@
1# Wirecore Changelog
2
3## Version 2933 - 2026-01-20
4
5### Critical Fix: Static File Serving from Pear Link (FIXED!)
6
7**Bug Fixed:**
8- ✅ Static files (HTML, CSS, JS) now load correctly when running from pear link
9- ✅ Uses `__dirname` to get the app's directory location
10- ✅ Works with both `pear run pear://...` and `pear run .`
11
12**What was broken:**
13- Running `pear run pear://... web 3003` would show "Not found" for web UI
14- API endpoints worked but HTML/CSS/JS files couldn't be found
15- Previous fix attempt using `Pear.config.cwd` didn't work (property not set)
16
17**The actual fix:**
18```javascript
19// Import bare-url to get fileURLToPath
20import { fileURLToPath } from 'bare-url'
21
22// Get __dirname equivalent in ESM
23const __filename = fileURLToPath(import.meta.url)
24const __dirname = path.dirname(__filename)
25
26// Use __dirname to locate public directory
27const PUBLIC_PATH = path.join(__dirname, 'public')
28```
29
30This works because:
311. `import.meta.url` gives the URL of the current module
322. `fileURLToPath()` converts it to a filesystem path
333. `path.dirname()` gets the directory
344. When running from `pear://`, files are cached in `~/.config/pear/by-dkey/...`
355. `__dirname` points to that cached location, where `public/` exists
36
37**How to update on ashatron:**
38```bash
39# Just restart with the pear link
40pkill -f "pear run"
41pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003
42
43# It will automatically fetch version 2933!
44# Web UI will now load at http://ashatron.local:3003
45```
46
47## Version 2921 - 2026-01-20
48
49### Enhanced Username Editing UI
50
51**Improvements:**
52- ✅ Beautiful profile modal with better layout
53- ✅ Inline username editing with edit button
54- ✅ Show/hide edit form for cleaner interface
55- ✅ Visual feedback during save ("Saving..." state)
56- ✅ Keyboard shortcuts (Enter to save, Esc to cancel)
57- ✅ Character limit (32 chars) with helper text
58- ✅ Improved styling for light and dark modes
59- ✅ Better copy-to-clipboard UX for public keys
60
61**UI Features:**
62- Click edit button (✏️) to show username input
63- Save/Cancel buttons for clear actions
64- Username displays prominently when set
65- "Not set" placeholder when empty
66- Smooth transitions and animations
67
68## Version 2914 - 2026-01-20
69
70### Username Support Added
71
72**New Features:**
73- ✅ Username storage in profile metadata
74- ✅ CLI command: `pear run . setname "YourName"`
75- ✅ API endpoints: GET/POST `/api/profile`
76- ✅ Usernames included in post metadata
77- ✅ Timeline displays usernames instead of pubkeys
78- ✅ Web UI for setting username in profile modal
79
80**Usage:**
81
82CLI:
83```bash
84# Set username
85pear run . setname "Alice"
86
87# Post with username
88pear run . post "Hello world!"
89```
90
91Web UI:
921. Click your profile button (👤)
932. Enter username in the text field
943. Click "Save"
95
96API:
97```bash
98# Get profile
99curl http://localhost:3003/api/profile
100
101# Set username
102curl -X POST http://localhost:3003/api/profile \
103 -H 'Content-Type: application/json' \
104 -d '{"username":"Alice"}'
105```
106
107**Technical Details:**
108- Username stored in Hyperbee under 'profile' key
109- Posts include `username` field in JSON metadata
110- Timeline falls back to "You" or pubkey if username not set
111- Username persists across sessions
112
113## Version 2907 - 2026-01-20
114
115### Critical Bug Fixes
116
117**Fixed: Mutex Destroyed Error**
118- Root cause: `finally` blocks always execute, even after `return`
119- Solution: Conditional cleanup for long-running commands
120- Impact: Web server now stable for POST operations
121
122**Fixed: Network Binding**
123- Changed server binding from `127.0.0.1` to `0.0.0.0`
124- Server now accessible from network
125
126**Fixed: Pear Permission Issues**
127- Workaround: Use `pear dump` for older Pear versions
128- Documentation added for deployment methods
129
130### Documentation Added
131- `PEAR_DEPLOYMENT_LESSONS.md` - Critical deployment knowledge
132- `BUG_FIXES.md` - Detailed bug analysis
133- `QUICK_START.md` - Simple deployment guide
134
135## Version 2898 - 2026-01-20
136
137### Web Interface Added
138
139**New Features:**
140- HTTP server with REST API
141- WebSocket for real-time updates
142- Browser-based UI (dumb client)
143- All P2P networking in Pear app
144
145**API Endpoints:**
146- GET `/api/whoami` - Get identity
147- GET `/api/timeline` - Get timeline
148- POST `/api/post` - Post message
149- POST `/api/follow` - Follow user
150- POST `/api/unfollow` - Unfollow user
151- GET `/api/follows` - List follows
152
153**Configuration:**
154- Default port: 3003
155- Configurable: `pear run . web <port>`
156- Network accessible: Binds to 0.0.0.0
157
158## Earlier Versions
159
160### Version 1.0.0 - Initial Release
161
162**Core Features:**
163- Hypercore-based personal feed
164- Hyperswarm P2P networking
165- CLI interface
166- Identity management (Keet Identity Key)
167- Follow/unfollow users
168- Timeline viewing
169- Message posting
pear-app/PACKAGING_STRATEGY.md +95 -0
……@@ -0,0 +1,95 @@
1# Pear Packaging Strategy - Questions Answered
2
3## The Question: Should we prune less?
4
5**Answer:** Ideally yes, but npm limitation makes it complex.
6
7## How Pear Selective Download Works
8
9When you stage an app with multiple platform prebuilds:
10- **Staged size:** ~300MB (all platforms: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64)
11- **Download per node:** ~40-50MB (only the platform-specific prebuilds)
12
13**Each node downloads only what it needs!**
14
15## Current Reality
16
17**Our current build:**
18- Platform: linux-x64 only
19- Staged size: ~46MB
20- Download size: ~40-50MB
21- Works on: Linux x86_64 machines (most servers)
22- Doesn't work on: ARM/Raspberry Pi, macOS, Windows
23
24**Why only linux-x64?**
25- `npm install` only downloads prebuilds for the current platform
26- To get all platforms, you need:
27 1. Build on each platform separately, OR
28 2. Use CI/CD (GitHub Actions with matrix builds), OR
29 3. Manually collect prebuilds from each platform
30
31## Verification Plan for Ashatron
32
33**What to test:**
341. Check Pear cache size before: `du -sh ~/.config/pear/app-storage/`
352. Run: `pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003`
363. Check app size after: `du -sh ~/.config/pear/app-storage/zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo/`
37
38**Expected result:** ~40-50MB downloaded (not 300MB)
39
40This proves selective download works, even though we only have one platform staged.
41
42## ARM/Raspberry Pi Support
43
44**Yes, Pear and holepunchto have ARM support!**
45
46To enable ARM deployment:
471. Stage from an ARM device (Pi, M1 Mac, etc.), OR
482. Use GitHub Actions matrix build to stage with all platforms
49
50**Without multi-platform staging:**
51- Current build works: x86_64 Linux servers (Ashatron, most VPS)
52- Current build won't work: Raspberry Pi, ARM servers
53
54**With multi-platform staging:**
55- Works everywhere: x86_64, ARM64, macOS, Windows
56- Each platform downloads only its prebuilds (~40-50MB)
57
58## Size Breakdown (from Discord)
59
60For context, a typical Pear CLI app has:
61- **Native binaries** (linux-x64 only): 40 MB
62 - bare-tls (TLS/SSL): 11 MB
63 - bare-crypto: 5.2 MB
64 - sodium-native: 18 MB (multiple copies)
65 - Other natives: 5.8 MB
66- **JavaScript modules:** 6 MB
67- **App code:** 0.3 MB
68- **Test data:** 7 MB
69
70**Total per platform:** ~40-50MB
71**All platforms (5):** ~200-300MB staged, but each node downloads only ~40-50MB
72
73## Recommendation
74
75**For now (linux-x64 only):**
761. Test on ashatron - verify ~40-50MB download
772. Confirm selective download works
783. Deploy to production linux-x64 servers
79
80**For multi-platform (future):**
811. Set up GitHub Actions with build matrix
822. Build on: ubuntu-latest, macos-latest, windows-latest, ubuntu-arm64
833. Stage from a machine that has all prebuilds
844. This enables: Raspberry Pi, M1 Mac, Windows, Linux ARM deployments
85
86## Key Insight from Discord
87
88> "have all the prebuilds and such in the pear drive and when a node goes to run the app it selectively downloads what it needs"
89
90**This is correct!** The Pear DHT/hypercore system:
91- Stores all platforms in the staged app
92- Each node downloads only blocks it needs (platform-specific natives)
93- Saves bandwidth and storage per deployment
94
95**The staged app is like a multi-platform container, nodes unpack only what they need.**
pear-app/PEAR_DEPLOYMENT_LESSONS.md +147 -0
……@@ -0,0 +1,147 @@
1
2## Session 2026-01-20: Critical Lessons Learned
3
4### Finally Blocks in JavaScript
5
6**CRITICAL BUG:** `finally` blocks ALWAYS execute, even after `return` statements!
7
8```javascript
9// ❌ WRONG - cleanup runs even after return
10async function main() {
11 try {
12 if (longRunningCommand) {
13 await startServer()
14 return // Keep server running
15 }
16 } finally {
17 await cleanup() // THIS STILL RUNS!
18 }
19}
20
21// ✅ CORRECT - conditionally skip cleanup
22async function main() {
23 const shouldCleanup = !isLongRunningCommand
24
25 try {
26 if (longRunningCommand) {
27 await startServer()
28 return
29 }
30 } finally {
31 if (shouldCleanup) {
32 await cleanup()
33 }
34 }
35}
36```
37
38**Impact:** Our web server was running but the `finally` block closed the Corestore, causing "Mutex has been destroyed" errors on every POST request.
39
40**Solution:** Track which commands are long-running and conditionally skip cleanup. Long-running processes should use `graceful-goodbye` for cleanup on termination signals instead.
41
42### Bare Module Network Binding
43
44**Issue:** `server.listen(port)` in bare-http1 defaults to `127.0.0.1` (localhost only).
45
46```javascript
47// ❌ Only accessible locally
48server.listen(port, () => {})
49
50// ✅ Accessible from network
51server.listen(port, '0.0.0.0', () => {})
52```
53
54This is different from Node.js http module behavior. Always explicitly specify the host for network-accessible servers.
55
56### Pear Permission System
57
58**Old Pear versions (v0.9609)** require interactive permission approval for `pear run pear://...`
59
60**Workaround:**
61```bash
62# Instead of: pear run pear://...
63# Use:
64pear dump pear://key /path/to/app
65cd /path/to/app
66pear run . [args]
67```
68
69**Future:** Newer Pear versions will allow typing "TRUST" on first run.
70
71### Selective Download Verification
72
73**Confirmed:** Pear's selective download works perfectly!
74
75- **Staged:** 46MB (linux-x64 only in our case)
76- **Downloaded:** 53MB (includes storage overhead)
77- **If multi-platform:** 300MB staged → still only 50MB downloaded per platform
78
79Each node downloads only the prebuilds it needs. This is a huge win for multi-platform distribution.
80
81### Development vs Production Storage
82
83**Development mode (`--dev`)** uses different storage path than production.
84
85```bash
86# Development
87pear run --dev . # Uses Pear.config.storage in dev mode
88
89# Production
90pear run . # Uses configured storage path
91```
92
93Be aware when testing - dev and production use separate identity/storage!
94
95### Module Resolution in Pear
96
97From previous sessions, we know:
98- **pear:// protocol has bugs** with node_modules resolution
99- **Local directory deployment is more reliable**
100- **Always include node_modules** in staged app (don't use pear.stage.ignore)
101
102Combined with our new finding: `pear dump` + local run is the most reliable deployment method.
103
104### Cleanup Best Practices
105
1061. **Long-running processes:** Use `graceful-goodbye` for cleanup
1072. **Short CLI commands:** Use `finally` block with conditional cleanup
1083. **Never** close the store while the server is still running
1094. **Always** reset ALL globals when cleanup runs, not just store
110
111```javascript
112// ✅ Complete cleanup
113async function cleanup() {
114 if (swarm) {
115 await swarm.destroy()
116 swarm = null
117 }
118 if (store) {
119 await store.close()
120 }
121 // Reset ALL globals
122 store = null
123 myFeed = null
124 metaBee = null
125 identity = null
126 follows = new Map()
127}
128```
129
130### Testing Checklist
131
132Before deploying:
1331. ✅ Test POST operations (catches mutex/cleanup issues)
1342. ✅ Test from different machine (catches network binding issues)
1353. ✅ Monitor download size (verifies selective download)
1364. ✅ Test with clean cache (catches dependency issues)
1375. ✅ Verify all storage paths work in target environment
138
139### Key Takeaway
140
141**Pear apps are JavaScript, but run in Bare runtime with different defaults:**
142- Network bindings default to localhost
143- Module resolution differs from Node.js
144- Storage paths vary by mode (dev vs production)
145- `finally` blocks are more dangerous in long-running processes
146
147**Always test the full deployment path, not just local dev mode!**
pear-app/PHASE2_TESTING_TODO.md +171 -0
……@@ -0,0 +1,171 @@
1# Phase 2: WebSocket & Storage Fixes - Testing TODO
2
3**Status:** DEFERRED (moved to Phase 3 first)
4**Created:** 2026-01-21
5
6## Known Issues to Fix Later
7
8### 1. bare-ws Browser Compatibility ⚠️
9
10**Problem:**
11- Browser WebSocket clients connect to bare-ws server then immediately disconnect (error 1006)
12- Server broadcasts work correctly with Node.js `ws` package test client
13- Browser clients fail to maintain connection
14
15**Current Workaround:**
16- 10-second polling fallback in browser (`web-server.js`)
17- Functional but inefficient
18
19**Testing:**
20- Test client exists: `test-websocket.js` (Node.js, works correctly)
21- Browser test: Open http://localhost:3003, check console for WebSocket errors
22
23**Possible Solutions:**
24- Option A: Find bare-ws configuration for browser clients (check bare-ws docs)
25- Option B: Switch to different WebSocket library (`ws`, `uWebSockets.js`)
26- Option C: Use bare-http with manual WebSocket upgrade
27- Option D: Implement SSE (Server-Sent Events) instead of WebSocket
28
29**Files Involved:**
30- `web-server.js` - WebSocket server setup
31- `public/timeline.html` - Browser WebSocket client
32
33---
34
35### 2. CLI Storage Lock Conflict 🔒
36
37**Problem:**
38- Cannot run CLI commands when web server is running
39- Both try to access same Corestore simultaneously
40- Error: Storage lock conflict
41
42**Current Workaround:**
43- Stop web server before using CLI commands
44- Restart web server after CLI operations
45
46**Testing:**
47```bash
48# Terminal 1: Start web server
49pear run pear://hash web 3003
50
51# Terminal 2: Try CLI (will fail)
52pear run pear://hash cli post "test"
53```
54
55**Possible Solutions:**
56- Option A: Web server exposes IPC endpoint for CLI (CLI becomes client to web server)
57- Option B: File-based locking with operation queue
58- Option C: Separate storage paths for CLI vs web (duplicate data, not ideal)
59- Option D: Single-instance mode (web server provides CLI functionality via HTTP API)
60
61**Files Involved:**
62- `index.js` - CLI command handlers
63- `web-server.js` - Web server initialization
64- Both use: `const store = new Corestore(storageDir)`
65
66---
67
68### 3. Feed Replication Efficiency 📡
69
70**Current Implementation:**
71- Active polling with `feed.update()` every 5 seconds for all followed feeds
72- Works reliably but may be inefficient with many follows
73
74**Why Polling is Needed:**
75- Hypercore `download` events only fire during initial sync
76- New blocks appended after sync don't trigger `download` event
77- Active polling with `feed.update()` required to discover new blocks
78
79**Testing:**
80```bash
81# Multi-peer test (2+ machines)
82# Peer A: Post message
83pear run pear://hash cli post "test from A"
84
85# Peer B: Should see within 5-10 seconds
86# (5 sec polling + download time)
87```
88
89**Optimization Ideas:**
90- Adaptive polling (slow down when no activity)
91- Event-driven updates (investigate why Hypercore events don't fire)
92- Hybrid approach (events + fallback polling)
93
94**Files Involved:**
95- `web-server.js` - Poll loop in `monitorFollowedFeeds()`
96- `public/timeline.html` - Browser polling fallback (10 seconds)
97
98---
99
100## Success Criteria (When Ready to Fix)
101
102- [ ] Browser WebSocket shows live updates without polling fallback
103- [ ] CLI commands work while web server running (no lock conflict)
104- [ ] 3+ peers can follow each other and see real-time updates
105- [ ] No disconnections or missed messages over 24 hours
106- [ ] Feed sync latency < 10 seconds (currently 5-15 seconds)
107
108---
109
110## Testing Protocol
111
112### Multi-Peer Stress Test
113
114**Setup:**
1151. 3 machines (tabertron, ashatron, remote laptop)
1162. Each running wirecore pear-app
1173. All following each other
1184. 24-hour uptime test
119
120**Automated Test Script:**
121```bash
122#!/bin/bash
123# post-loop.sh - Post every 5 minutes for 24 hours
124for i in {1..288}; do
125 pear run pear://hash cli post "Auto-post #$i at $(date)"
126 sleep 300
127done
128```
129
130**Metrics to Collect:**
131- Post delivery latency (time from post to appearance on follower)
132- Feed sync success rate (% of posts delivered)
133- Hyperswarm connection stability (reconnections, peer count)
134- Memory usage over time (check for leaks)
135- CPU usage (polling overhead)
136
137### WebSocket Test Script
138
139**Current test client** (`test-websocket.js`):
140- Connects to ws://localhost:3003/ws
141- Listens for broadcast messages
142- Logs all received messages
143- Works correctly (proves server broadcasts work)
144
145**Browser test needed:**
146- Open http://localhost:3003 in browser
147- Check console for WebSocket connection errors
148- Verify real-time updates (should not need polling)
149
150---
151
152## Implementation Priority
153
154**After Phase 3-7 Complete:**
1551. Fix CLI storage lock (most disruptive to developer UX)
1562. Fix WebSocket browser compatibility (needed for production)
1573. Optimize feed replication (performance enhancement)
158
159**Estimated Effort:**
160- CLI lock fix: 1-2 days
161- WebSocket fix: 2-3 days (may require library change)
162- Replication optimization: 1 week (research + implementation)
163
164---
165
166## Notes
167
168- All issues are **functional workarounds exist**
169- Moving to Phase 3 (Content-by-Hash) first is reasonable
170- These issues don't block Phase 3 implementation
171- Revisit after Phase 7 (UI/UX polish) or when issues become blocking
pear-app/PORT_CONFIG.md +42 -0
……@@ -0,0 +1,42 @@
1# Port Configuration
2
3Wirecore web server defaults to **port 3003** to avoid conflicts with other services (e.g., OpenWebUI on port 3000).
4
5## Usage
6
7### Default Port (3003)
8```bash
9pear run -d .
10# or
11npm run dev
12```
13
14### Custom Port
15```bash
16# Using the 'web' command with custom port
17pear run -d . web 8080
18
19# or use npm scripts
20npm run dev:3000 # Start on port 3000
21npm run dev:8080 # Start on port 8080
22```
23
24## Port Precedence
25
26The server determines the port in this order:
271. Command argument: `pear run . web <port>`
282. Default: `3003`
29
30## CLI Commands
31
32All other CLI commands work as before:
33```bash
34pear run . init # Initialize identity
35pear run . post "Hello world" # Post message
36pear run . follow <pubkey> [name] # Follow user
37pear run . timeline # View timeline
38pear run . whoami # Show identity
39pear run . follows # List follows
40```
41
42The web server mode (no arguments or `web` command) starts the HTTP/WebSocket server for browser access.
pear-app/QUICK_START.md +252 -0
……@@ -0,0 +1,252 @@
1# Wirecore Quick Start
2
3Simple deployment instructions for running Wirecore on any machine.
4
5## Pear Link
6```
7pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo
8```
9
10## ⚠️ Important Limitation
11
12**The web interface ONLY works when running from a local directory.**
13
14Due to limitations in Pear v0.9609, static files (HTML/CSS/JS) cannot be served when running directly from `pear://` links. The CLI commands work fine from pear links, but the web server requires `pear dump` deployment.
15
16## One-Time Setup
17
18### 1. Install Pear (if not installed)
19```bash
20npx pear
21```
22
23### 2. Download Wirecore Locally (REQUIRED for Web Interface)
24
25**Important:** Due to Pear limitations, the web interface (HTTP server with static files) only works when running from a local directory. Download the app with `pear dump`:
26
27```bash
28# Download to local directory
29pear dump pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo ~/wirecore
30
31# Initialize your identity
32cd ~/wirecore
33pear run . init
34
35# Set your username (recommended!)
36pear run . setname "YourName"
37```
38
39This creates:
40- Your public/private keypair
41- Your personal feed
42- Local storage at `~/wirecore/wirecore-storage/`
43
44Save your public key! You'll share this with others to follow you.
45
46### Alternative: CLI-Only Mode (Direct from Pear Link)
47
48For CLI commands without the web interface, you can run directly from the pear link:
49
50```bash
51# CLI commands work directly from pear link
52pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo init
53pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo post "Hello!"
54pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo timeline
55```
56
57**Note:** The web server won't work from pear:// links due to Pear's virtual filesystem limitations.
58
59## Running Wirecore
60
61### Start Web Server (Default Port 3003)
62
63**From local directory (REQUIRED):**
64```bash
65cd ~/wirecore
66pear run . web 3003
67```
68
69Access at: `http://localhost:3003`
70
71**Why local directory is required:** Pear's virtual filesystem (pear:// protocol) doesn't provide API access to bundled static files (HTML/CSS/JS) in current versions. The pear-appdrive IPC is not available, so static file serving only works from local directories.
72
73### Start Web Server (Custom Port)
74
75**Direct pear link:**
76```bash
77pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 8080
78```
79
80**Or from local directory:**
81```bash
82cd ~/wirecore
83pear run . web 8080
84```
85
86Access at: `http://localhost:8080`
87
88## CLI Commands
89
90**Using direct pear link (recommended):**
91
92```bash
93# View your identity
94pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo whoami
95
96# Set your username (recommended!)
97pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo setname "YourName"
98
99# Post a message
100pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo post "Hello Wirecore!"
101
102# Follow someone (use their public key)
103pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo follow <their-public-key> "Alice"
104
105# View your timeline
106pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo timeline
107
108# List who you're following
109pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo follows
110```
111
112**Or from local directory (`cd ~/wirecore`):**
113
114```bash
115pear run . whoami
116pear run . setname "YourName"
117pear run . post "Hello Wirecore!"
118pear run . follow <their-public-key> "Alice"
119pear run . timeline
120pear run . follows
121```
122
123## Web Interface
124
125Once the server is running:
126
1271. **Open browser:** http://localhost:3003
1282. **Set username:** Click 👤 profile, enter username, click Save
1293. **Post:** Click the ✏️ button to compose
1304. **Follow:** Click the 👤+ button, enter public key
1315. **Refresh:** Click 🔄 to reload timeline
1326. **Profile:** Click your profile to see your keys
133
134## Sharing Your Identity
135
136Your public key is how others follow you:
137
138```bash
139# Get your public key
140pear run . whoami
141
142# Share it with others
143# They run: pear run . follow <your-public-key> "Your Name"
144```
145
146## Deployment Scenarios
147
148### Personal Desktop (Direct Pear Link)
149```bash
150# First time: Grant TRUST when prompted
151pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo init
152pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003
153
154# Access: http://localhost:3003
155```
156
157### Server (Network Access) - Example: ashatron
158```bash
159ssh ashatron.local
160
161# First time: Grant TRUST and initialize
162pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo init
163pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo setname "ashatron"
164
165# Start server (binds to 0.0.0.0)
166pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003
167
168# Access: http://ashatron.local:3003
169```
170
171**Pro tip:** Create a shell alias for the pear link!
172```bash
173# Add to ~/.bashrc or ~/.zshrc
174alias wirecore='pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo'
175
176# Then use:
177wirecore web 3003
178wirecore post "Hello!"
179wirecore whoami
180```
181
182### Multiple Machines
183Each machine:
1841. Install Pear: `npx pear`
1852. Grant TRUST: `pear run pear://... init`
1863. Set unique username per machine
1874. Run web server
188
189All machines join the same P2P network automatically!
190
191## Troubleshooting
192
193### Port already in use
194```bash
195# Find and kill process
196lsof -ti:3003 | xargs kill
197
198# Or use different port
199pear run . web 8080
200```
201
202### Permission error on pear run
203If you get "Permission required to run key":
204```bash
205# Already using pear dump method, so this shouldn't happen
206# But if it does, you're already running from local directory
207cd ~/wirecore
208pear run . web 3003
209```
210
211### Can't access from network
212Server should already bind to 0.0.0.0. Check firewall:
213```bash
214# Allow port 3003
215sudo ufw allow 3003/tcp
216```
217
218### Clear and restart
219```bash
220# Stop server
221pkill -f "pear run"
222
223# Clear storage and start fresh
224rm -rf ~/wirecore/wirecore-storage
225pear run . init
226pear run . web
227```
228
229## Data Location
230
231- **Code:** `~/wirecore/` (downloaded pear app)
232- **Storage:** `~/wirecore/wirecore-storage/` (your identity, feed, follows)
233- **Pear cache:** `~/.config/pear/` (pear runtime)
234
235## Updating
236
237To get the latest version:
238```bash
239cd ~/wirecore
240pear dump pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo ~/wirecore-new
241mv ~/wirecore ~/wirecore-backup
242mv ~/wirecore-new ~/wirecore
243
244# Your identity is preserved in wirecore-storage/
245pear run . web
246```
247
248## Support
249
250- **Pear Link:** `pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo`
251- **Version:** 2933 (Fixed pear link web UI - now working!)
252- **Platform:** linux-x64 (ARM/macOS need multi-platform build)
pear-app/README.md +81 -0
……@@ -0,0 +1,81 @@
1# Wirecore - P2P Social Network
2
3Decentralized social network built on Hypercore Protocol.
4
5## Quick Start
6
7### Web Interface (Recommended)
8
9The web interface requires local deployment:
10
11```bash
12# Download app
13pear dump pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo ~/wirecore
14
15# Initialize
16cd ~/wirecore
17pear run . init
18pear run . setname "YourName"
19
20# Start web server
21pear run . web 3003
22```
23
24Open http://localhost:3003 in your browser.
25
26### CLI Only
27
28CLI commands work directly from the pear link:
29
30```bash
31# Initialize
32pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo init
33
34# Post a message
35pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo post "Hello!"
36
37# View timeline
38pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo timeline
39```
40
41## Why pear dump?
42
43**Static file serving doesn't work from pear:// links in current Pear versions (v0.9609).**
44
45We attempted multiple solutions:
46- ✗ pear-appdrive (IPC not available)
47- ✗ import.meta.asset() (doesn't resolve bundled files)
48- ✗ Filesystem paths (files not extracted)
49
50Only solution: `pear dump` to local directory.
51
52## Full Documentation
53
54- [QUICK_START.md](./QUICK_START.md) - Complete setup guide
55- [PEAR_DEPLOYMENT_LESSONS.md](../../PEAR_DEPLOYMENT_LESSONS.md) - Technical details
56
57## Pear Link
58
59```
60pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo
61```
62
63## Features
64
65- ✅ Personal feed (Hypercore)
66- ✅ Follow other users
67- ✅ P2P replication (Hyperswarm)
68- ✅ Web interface with real-time updates
69- ✅ Username support
70- ✅ CLI commands
71- ✅ Local-first architecture
72
73## Architecture
74
75- **Hypercore** - Personal append-only log
76- **Hyperbee** - Metadata storage (follows, profile)
77- **Hyperswarm** - P2P networking
78- **bare-http1** - HTTP server
79- **bare-ws** - WebSocket server
80
81Each user has their own writable feed. Following someone means joining their Hyperswarm topic and replicating their feed in read-only mode.
pear-app/REFACTOR_DEPLOYMENT.md +569 -0
……@@ -0,0 +1,569 @@
1# Wirecore Refactor - Deployment & Testing Guide
2
3**Date**: 2026-01-21
4**Purpose**: Deployment considerations for ANProto refactored version
5
6---
7
8## Critical Dependencies Added
9
10### sodium-universal
11
12**What**: Pure JavaScript libsodium (crypto library for ANProto)
13**Why**: ANProto uses ed25519 signatures via sodium-universal
14**Risk**: Must work in Bare runtime (not just Node.js)
15
16**Testing Required:**
17```bash
18# Test in Bare runtime
19pear run -d . cli init
20pear run -d . cli whoami
21```
22
23**Verify**: No "Cannot find module" errors related to sodium
24
25---
26
27## New Files in lib/ Directory
28
29### Files Added:
30- `lib/anproto.js` - ANProto signature protocol
31- `lib/yaml-helper.js` - YAML message formatting
32
33**Pear Staging Impact:**
34- ✅ `.pearignore` already includes `!lib/` pattern
35- ✅ No changes needed to staging config
36- ⚠️ Verify lib/ is included in staged bundle
37
38**Verification:**
39```bash
40npm run stage
41# Check staging output includes lib/
42ls .pear/staging-*/lib/
43```
44
45---
46
47## Module Resolution in Bare
48
49### sodium-universal Dependencies
50
51**Potential Issues:**
52- sodium-universal has multiple implementations (WASM, JS, native)
53- Bare runtime may resolve differently than Node.js
54- transitive dependencies might fail under `pear://` protocol
55
56**Mitigation:**
571. **Always include node_modules** (already done in .pearignore)
582. **Test in production mode** (not just --dev)
593. **Monitor for module resolution errors**
60
61**Test Commands:**
62```bash
63# Development mode
64pear run -d . cli init
65
66# Production mode (more realistic)
67npm run stage
68pear run pear://wirecore cli init
69
70# Or use pear dump (more reliable)
71pear dump pear://wirecore /tmp/wirecore-test
72cd /tmp/wirecore-test
73pear run . cli init
74```
75
76---
77
78## Storage Migration Strategy
79
80### Fresh Start Approach
81
82**Current Decision**: No migration, fresh identities
83
84**User Impact:**
85- Old storage: `wirecore-storage/` (IdentityKey system)
86- New storage: `wirecore-storage/` (ANProto system)
87- **Not compatible** - users create new identity
88
89**Deployment Steps:**
901. Warn users in README: "Breaking change, new identity required"
912. Document backup command: `mv wirecore-storage wirecore-storage-old`
923. Provide export tool (future): Extract old posts to JSON
93
94**Testing:**
95```bash
96# Backup old storage
97mv wirecore-storage wirecore-storage-backup
98
99# Test fresh init
100pear run -d . cli init
101
102# Verify ANProto keypair
103pear run -d . cli whoami
104# Should show: "ANProto public key: ..." (base64, 44 chars)
105```
106
107---
108
109## Testing Strategy
110
111### Unit Tests (Brittle)
112
113**Need to create:**
114
115#### test/anproto.test.js
116```javascript
117import { test } from 'brittle'
118import { ANProto } from '../lib/anproto.js'
119
120test('Generate ANProto keypair', async (t) => {
121 const { keypair, publicKey, secretKey } = ANProto.gen()
122 t.is(keypair.length, 132, 'Keypair is 132 characters')
123 t.is(publicKey.length, 44, 'Public key is 44 characters')
124 t.is(secretKey.length, 88, 'Secret key is 88 characters')
125})
126
127test('Sign and verify hash', async (t) => {
128 const { keypair, publicKey, secretKey } = ANProto.gen()
129 const kp = { publicKey: Buffer.from(publicKey, 'base64'), secretKey: Buffer.from(secretKey, 'base64') }
130
131 const hash = ANProto.hash('test content')
132 const signature = ANProto.sign(hash, kp)
133
134 t.ok(signature.startsWith(publicKey), 'Signature starts with public key')
135
136 const verified = ANProto.open(signature)
137 t.is(verified.hash, hash, 'Verified hash matches original')
138 t.is(verified.publicKey, publicKey, 'Verified public key matches')
139})
140```
141
142#### test/yaml-helper.test.js
143```javascript
144import { test } from 'brittle'
145import { YAMLHelper } from '../lib/yaml-helper.js'
146
147test('Create YAML with metadata', async (t) => {
148 const yaml = YAMLHelper.create({ name: 'test', image: 'hash123' }, 'Hello world')
149 t.ok(yaml.includes('name: test'), 'Has name metadata')
150 t.ok(yaml.includes('image: hash123'), 'Has image metadata')
151 t.ok(yaml.includes('---'), 'Has separator')
152 t.ok(yaml.includes('Hello world'), 'Has content')
153})
154
155test('Parse YAML back to metadata and content', async (t) => {
156 const original = YAMLHelper.create({ name: 'test' }, 'Content here')
157 const { metadata, content } = YAMLHelper.parse(original)
158
159 t.is(metadata.name, 'test', 'Name parsed correctly')
160 t.is(content, 'Content here', 'Content parsed correctly')
161})
162```
163
164#### test/init.test.js
165```javascript
166import { test } from 'brittle'
167import { exec } from 'child_process'
168import { promisify } from 'util'
169import fs from 'fs'
170import path from 'path'
171
172const execAsync = promisify(exec)
173
174test('Initialize with ANProto keypair', async (t) => {
175 const testStorage = './test-storage-' + Date.now()
176
177 try {
178 // Run init command
179 const { stdout } = await execAsync(`STORAGE_PATH=${testStorage} pear run -d . cli init`)
180
181 t.ok(stdout.includes('ANProto'), 'Output mentions ANProto')
182 t.ok(stdout.includes('initialized'), 'Initialization successful')
183
184 // Verify storage created
185 t.ok(fs.existsSync(testStorage), 'Storage directory created')
186 t.ok(fs.existsSync(path.join(testStorage, 'cores')), 'Cores directory exists')
187
188 // Run whoami to verify
189 const { stdout: whoami } = await execAsync(`STORAGE_PATH=${testStorage} pear run -d . cli whoami`)
190 t.ok(whoami.includes('ANProto public key'), 'Shows ANProto public key')
191
192 } finally {
193 // Cleanup
194 if (fs.existsSync(testStorage)) {
195 fs.rmSync(testStorage, { recursive: true })
196 }
197 }
198})
199```
200
201**Run tests:**
202```bash
203npm test
204# or
205brittle test/*.js
206```
207
208---
209
210## Pear Staging Checklist
211
212### Before Staging
213
214- [ ] All dependencies installed: `npm install`
215- [ ] Unit tests pass: `npm test`
216- [ ] Manual test: Init new identity
217- [ ] Manual test: Whoami shows ANProto key
218- [ ] Manual test: Post message (still JSON for now)
219- [ ] Manual test: Timeline loads
220
221### Staging Process
222
223```bash
224# 1. Prune unnecessary prebuilds
225npm run prune
226
227# 2. Stage the app
228npm run stage
229# or manually:
230pear stage wirecore .
231
232# 3. Verify staging output
233ls -lh .pear/staging-*
234# Check size is reasonable (~50MB for single platform)
235
236# 4. Verify lib/ included
237ls .pear/staging-*/lib/
238# Should show: anproto.js, yaml-helper.js
239
240# 5. Verify node_modules included
241ls .pear/staging-*/node_modules/ | grep sodium
242# Should show: sodium-universal, sodium-javascript, etc.
243```
244
245### Testing Staged App
246
247```bash
248# Test via pear:// link
249pear run pear://wirecore cli init
250
251# Or dump and test locally (more reliable)
252pear dump pear://wirecore /tmp/wirecore-test
253cd /tmp/wirecore-test
254pear run . cli init
255pear run . cli whoami
256```
257
258---
259
260## Seeding for Distribution
261
262### When to Seed
263
264**Only seed after:**
2651. ✅ All tests pass
2662. ✅ Staging successful
2673. ✅ Local testing complete
2684. ✅ Remote testing complete (different machine)
2695. ✅ No breaking bugs found
270
271### Seeding Process
272
273```bash
274# Seed to DHT
275npm run seed
276# or:
277pear seed wirecore .
278
279# Outputs: pear://long-key-here
280```
281
282### Post-Seed Testing
283
284**Critical Tests:**
285
2861. **Fresh Download Test:**
287 ```bash
288 # On clean machine (or cleared cache)
289 rm -rf ~/.config/pear/app-storage/*
290 pear run pear://your-key cli init
291 ```
292
2932. **Module Resolution Test:**
294 ```bash
295 # Verify sodium-universal works
296 pear run pear://your-key cli whoami
297 # Should NOT error with "Cannot find module"
298 ```
299
3003. **Multi-Machine Test:**
301 ```bash
302 # On different machine (different network)
303 pear run pear://your-key cli init
304 pear run pear://your-key web 3003
305 # Access from browser
306 ```
307
3084. **Production Storage Test:**
309 ```bash
310 # Not in --dev mode
311 pear run pear://your-key cli init
312 ls ~/.config/pear/app-storage/by-dkey/*/wirecore-storage
313 # Should show ANProto storage structure
314 ```
315
316---
317
318## Known Issues & Mitigations
319
320### Issue 1: Module Resolution Under pear://
321
322**Problem**: Pear's module resolution has bugs with node_modules
323
324**Symptoms**:
325- "Cannot find module 'sodium-universal'"
326- "Cannot find module 'sodium-javascript'"
327
328**Mitigation**:
3291. Always include node_modules in staged app (✅ already done)
3302. If errors persist, use `pear dump` + local run instead of `pear://`
3313. Report issue to Pear team with reproducible case
332
333**Testing:**
334```bash
335# Test both methods
336pear run pear://wirecore cli init # May fail
337pear dump pear://wirecore /tmp/test && cd /tmp/test && pear run . cli init # More reliable
338```
339
340### Issue 2: Bare vs Node.js Differences
341
342**Problem**: Bare runtime behaves differently than Node.js
343
344**Differences**:
345- Network bindings default to localhost (✅ already fixed with '0.0.0.0')
346- Some npm packages may not work
347- `finally` blocks always run (✅ already handled)
348
349**Mitigation**:
350- Test in Bare/Pear, not just Node.js
351- Use Bare-compatible packages
352- Check holepunchto ecosystem for alternatives
353
354### Issue 3: Selective Download
355
356**Status**: ✅ Works great (verified in previous sessions)
357
358**Expected**:
359- Staged: ~46MB (single platform) or ~300MB (multi-platform)
360- Downloaded: ~50MB per user (only their platform)
361
362**Verification**:
363```bash
364# Check download size
365du -sh ~/.config/pear/app-storage/by-dkey/*/
366```
367
368### Issue 4: Storage Path Differences
369
370**Problem**: Dev mode uses different storage than production
371
372**Impact**: Testing in `--dev` mode doesn't catch production storage issues
373
374**Mitigation**:
375- Always test without `--dev` flag before seeding
376- Document storage locations in README
377- Provide storage-path command: `pear run . cli storage-path`
378
379---
380
381## Deployment Checklist
382
383### Phase 1: ANProto Identity (Current)
384
385- [ ] Unit tests created and passing
386 - [ ] test/anproto.test.js
387 - [ ] test/yaml-helper.test.js
388 - [ ] test/init.test.js
389- [ ] Manual testing complete
390 - [ ] Init creates ANProto keypair
391 - [ ] Whoami shows base64 public key
392 - [ ] Feed key = ANProto public key
393 - [ ] Keypair stored in Hyperbee metadata
394- [ ] Bare runtime testing
395 - [ ] sodium-universal works
396 - [ ] No module resolution errors
397- [ ] Staging testing
398 - [ ] lib/ directory included
399 - [ ] node_modules included
400 - [ ] Reasonable bundle size
401- [ ] Production testing
402 - [ ] Works without --dev flag
403 - [ ] Storage in correct location
404 - [ ] Multi-machine test passed
405
406### Phase 2-6: Future Phases
407
408Each subsequent phase needs:
409- [ ] Unit tests for new features
410- [ ] Integration tests for P2P features
411- [ ] Staging verification
412- [ ] Production testing
413- [ ] Multi-node P2P testing
414
415---
416
417## Testing Workflow
418
419### Development Loop
420
421```bash
422# 1. Make changes
423vim index.js
424
425# 2. Run unit tests
426npm test
427
428# 3. Test locally in dev mode
429pear run -d . cli init
430pear run -d . cli whoami
431
432# 4. Test locally in production mode
433rm -rf wirecore-storage
434pear run . cli init
435pear run . cli whoami
436
437# 5. If all good, stage
438npm run stage
439
440# 6. Test staged version
441pear run pear://wirecore cli whoami
442
443# 7. If all good, commit
444git add -A
445git commit -m "..."
446```
447
448### Pre-Seed Checklist
449
450Before running `npm run seed`:
451
4521. ✅ All tests pass
4532. ✅ Code committed to git
4543. ✅ Version bumped in package.json
4554. ✅ README updated
4565. ✅ Tested on clean machine
4576. ✅ No known critical bugs
458
459### Post-Seed Verification
460
461After seeding:
462
4631. Test download on different machine
4642. Test all CLI commands
4653. Test web interface
4664. Monitor for errors
4675. Test storage persistence (restart app)
4686. Announce new version to users
469
470---
471
472## Future Considerations
473
474### Phase 2-6 Deployment Additions
475
476**Phase 2: Content Cache (Hyperbee)**
477- Test Hyperbee content storage
478- Verify hash-based lookups work
479- Test large content (images)
480
481**Phase 3: YAML Format**
482- Test YAML parsing/creation
483- Verify metadata extraction
484- Test special characters in content
485
486**Phase 4: Signature-Only Feeds**
487- Test ANProto signature verification
488- Verify feed sync (signatures only)
489- Test bandwidth savings
490
491**Phase 5: P2P Content Gossip**
492- Test Protomux channel setup
493- Test content requests between peers
494- Test multi-node scenarios
495- Test relay fallback
496
497**Phase 6: Full Integration**
498- Test complete workflow end-to-end
499- Test image upload → storage → P2P distribution
500- Stress test with multiple nodes
501- Performance benchmarks
502
503---
504
505## Critical Lessons Applied
506
507From PEAR_DEPLOYMENT_LESSONS.md:
508
509✅ **Finally blocks** - Handled with `shouldCleanup` flag
510✅ **Network binding** - Using '0.0.0.0' for web server
511✅ **Module resolution** - Keeping node_modules in staged app
512✅ **Cleanup best practices** - Using graceful-goodbye for long-running processes
513✅ **Selective download** - Verified working in previous tests
514
515**New Lessons to Watch:**
516- ⚠️ sodium-universal compatibility in Bare runtime
517- ⚠️ ANProto signature performance in production
518- ⚠️ Hyperbee content cache performance
519- ⚠️ Protomux channel stability
520
521---
522
523## Emergency Rollback
524
525If deployment fails critically:
526
5271. **Don't seed broken version** - Test thoroughly first
5282. **Users can roll back** - Old version still available at old pear:// key
5293. **Provide migration path** - Document how to export old data
5304. **Fix and re-seed** - New pear:// key for fixed version
531
532**Rollback Command:**
533```bash
534# Users can run old version
535pear run pear://old-key cli timeline
536
537# Or dump old version locally
538pear dump pear://old-key ~/wirecore-old
539cd ~/wirecore-old
540pear run . cli timeline
541```
542
543---
544
545## Success Metrics
546
547### Phase 1 Success:
548- ✅ Fresh init creates ANProto keypair
549- ✅ Public key is base64 format (44 characters)
550- ✅ Feed key = ANProto public key (unified identity)
551- ✅ Keypair persists in Hyperbee metadata
552- ✅ All CLI commands work
553- ✅ Web interface loads
554- ✅ No module resolution errors
555- ✅ No regression in existing features
556
557### Overall Success:
558- All 6 phases implemented
559- All tests passing
560- Deployed to DHT
561- Multi-node testing complete
562- P2P content distribution working
563- User feedback positive
564
565---
566
567**Status**: Phase 1 deployment guide complete
568**Next**: Create unit tests and run deployment checklist
569**Date**: 2026-01-21
pear-app/STATIC_FILES_SOLUTION.md +164 -0
……@@ -0,0 +1,164 @@
1# Serving Static Files in Pear Apps from pear:// Links
2
3**Problem Duration:** ~4 hours of debugging
4**Date:** 2026-01-20
5
6## The Problem
7
8When running a Pear app from a `pear://` link, static files (HTML, CSS, JS) could not be served via HTTP. The files would load fine in local development (`pear run -d .`) but fail with 404 errors when running from `pear run pear://...`.
9
10### What We Tried (That Didn't Work)
11
121. ❌ **`import.meta.asset()`** - Only works for module assets defined in package.json exports, not arbitrary bundled files
132. ❌ **`require.asset()`** - Same limitation as above
143. ❌ **Relative paths with `fs.readFileSync()`** - CWD is the user's home directory, not the app directory
154. ❌ **`import.meta.url` to construct paths** - Returns `pear://key/public/file.html` but `fs.readFileSync()` can't read `pear://` URLs
165. ❌ **Opening Hyperdrive with `Pear.app.key`** - Created a new empty Hyperdrive instead of accessing the existing one
176. ❌ **Direct IPC access via `Pear[Pear.constructor.IPC]`** - Was undefined on older Pear versions
18
19### Why It Was Confusing
20
21**Critical misunderstanding:** We thought Pear extracted app files to `~/.config/pear/by-dkey/` like traditional runtimes.
22
23**Reality:** Pear apps are stored as **Hyperdrives in RocksDB block storage**. The files are NEVER extracted to the filesystem.
24
25```bash
26# These directories only contain runtime binaries, NOT app files:
27~/.config/pear/by-dkey/<hash>/bin/
28~/.config/pear/by-dkey/<hash>/lib/
29~/.config/pear/by-dkey/<hash>/prebuilds/
30
31# App files exist only in Hyperdrive storage (RocksDB):
32~/.config/pear/cores/
33~/.config/pear/storage/
34# (not human-readable - it's block storage)
35```
36
37## The Solution: `pear-appdrive`
38
39Use the official `pear-appdrive` module to access the app bundle (read-only):
40
41### Installation
42
43```bash
44npm install pear-appdrive
45```
46
47### Code
48
49```javascript
50import { default as AppDrive } from 'pear-appdrive'
51import fs from 'bare-fs'
52import path from 'bare-path'
53
54const isPear = typeof Pear !== 'undefined'
55
56// At startup - access the app bundle
57let appDrive = null
58if (isPear) {
59 appDrive = new AppDrive()
60 await appDrive.ready()
61}
62
63// In HTTP handler - serve static files
64async function serveStatic(req, res, url) {
65 const fileName = url.pathname === '/' ? 'index.html' : url.pathname.substring(1)
66
67 let data
68 if (appDrive) {
69 // Running from pear:// - read from app bundle
70 const filePath = '/public/' + fileName
71 data = await appDrive.get(filePath)
72 if (!data) throw new Error('File not found')
73 } else {
74 // Local dev - read from filesystem
75 const localPath = path.join('public', fileName)
76 data = fs.readFileSync(localPath)
77 }
78
79 res.writeHead(200, { 'Content-Type': getContentType(fileName) })
80 res.end(data)
81}
82```
83
84## Key Concepts
85
86### Two Separate Drives
87
881. **App Bundle Drive (read-only)** - Your shipped code
89 - Accessed via `pear-appdrive`
90 - Contains: index.js, public/, package.json, etc.
91 - Read-only, immutable
92
932. **User Data Drive (read/write)** - Runtime storage
94 - Created with `new Hyperdrive(new Corestore(Pear.config.storage))`
95 - Contains: user data, state, databases
96 - Writable, replicated
97
98### How `pear-appdrive` Works
99
100Internally, it accesses Pear's IPC to read from the already-running app Hyperdrive:
101
102```javascript
103// What pear-appdrive does internally:
104const ipc = Pear[Pear.constructor.IPC]
105const data = await ipc.get({ key: '/public/index.html' })
106```
107
108It's a wrapper that makes this easier and more reliable.
109
110## Requirements
111
112- **Pear runtime:** Updated version (the IPC API was undefined in older versions like 0.9609)
113- **On ashatron:** Required `npx pear@latest --upgrade` to get working IPC
114
115## Testing
116
117### Local Development
118```bash
119pear run -d . web 3003
120# Uses fs.readFileSync() - works immediately
121```
122
123### From pear:// Link
124```bash
125# Stage and seed
126pear stage pear://yourkey
127pear seed pear://yourkey .
128
129# On remote machine
130pear run pear://yourkey web 3003
131# Uses pear-appdrive - now works!
132```
133
134## Lessons Learned
135
1361. **Pear apps are Hyperdrives**, not extracted file trees
1372. **`pear-appdrive` is the canonical way** to read bundled files
1383. **Always check Pear version** - older versions had incomplete APIs
1394. **File paths in app bundle** start with `/` (e.g., `/public/index.html`)
1405. **Clear cache when testing** - `rm -rf ~/.config/pear/app-storage/by-dkey/<discovery-key>/`
141
142## Update: Web Interface Fix (2026-01-20)
143
144After solving the static file serving issue, the web interface loaded but was non-functional ("can't click on anything").
145
146**Root Cause:** The `init()` function in `app-simple.js` called API endpoints before setting up event listeners. If API calls failed, event listeners never attached, making buttons unresponsive.
147
148**Fixes Applied:**
1491. **Reordered initialization** - Event listeners now attach FIRST, before API calls
1502. **Added error handling** - Each initialization step wrapped in try-catch so failures don't stop the entire init
1513. **Fixed variable redeclaration** - `showProfileModal()` had `usernameDisplay` declared twice in same scope
152
153**Changes:**
154- `public/app-simple.js` lines 14-31: Reordered init() to call setupEventListeners() first
155- `public/app-simple.js` lines 329-380: Renamed second `usernameDisplay` to `usernameDisplayEl`
156
157**Result:** Web interface now works correctly from `pear://` links. All buttons functional even if network/API is slow.
158
159## References
160
161- pear-appdrive: https://github.com/holepunchto/pear-appdrive
162- Pear API docs: https://docs.pears.com/reference/api
163- Our implementation: `index.js` lines 15-25 and `serveStatic()` function
164- Web interface fix: `public/app-simple.js` lines 14-31 (init reordering)
pear-app/UPDATE_ASHATRON.md +168 -0
……@@ -0,0 +1,168 @@
1# Update Wirecore on Ashatron
2
3Quick guide to update wirecore on ashatron to the latest version.
4
5## Current Status
6- **Ashatron:** Running older version
7- **Latest:** Version 2921 (Enhanced username UI)
8- **Pear Link:** `pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo`
9- **Trusted:** Yes (TRUST granted)
10
11## Method 1: Direct Pear Link (Recommended - Now Trusted!)
12
13After granting TRUST, you can run directly from the pear link:
14
15```bash
16ssh ashatron.local
17
18# Stop old server (if running)
19pkill -f "pear run"
20
21# Run latest version directly
22pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003
23```
24
25**Note:** Data is stored in Pear's storage directory, so updates are automatic!
26
27## Method 2: Update Existing Installation
28
29SSH into ashatron and update the existing installation:
30
31```bash
32ssh ashatron.local
33
34# Stop the current server (if running)
35pkill -f "pear run"
36
37# Navigate to wirecore directory
38cd /tmp/wirecore-test # or ~/wirecore
39
40# Remove old code (keeps data!)
41rm -rf !(wirecore-storage)
42
43# Download latest version
44pear dump pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo .
45
46# Restart server
47pear run . web 3003
48```
49
50**Note:** Your identity and data in `wirecore-storage/` will be preserved!
51
52## Method 2: Fresh Download
53
54If you want a completely fresh installation:
55
56```bash
57ssh ashatron.local
58
59# Stop the current server
60pkill -f "pear run"
61
62# Backup your storage (optional but recommended)
63cp -r /tmp/wirecore-test/wirecore-storage ~/wirecore-backup
64
65# Remove old installation
66rm -rf /tmp/wirecore-test
67
68# Download latest version
69pear dump pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo /tmp/wirecore-test
70
71# Restore your storage
72mv ~/wirecore-backup /tmp/wirecore-test/wirecore-storage
73
74# Start server
75cd /tmp/wirecore-test
76pear run . web 3003
77```
78
79## First-Time Setup (TRUST Required)
80
81When running on a new machine for the first time:
82
83```bash
84ssh ashatron.local
85
86# First run - will prompt: "Use pear run again to execute trusted application"
87pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo
88
89# Type: TRUST
90
91# Then initialize your identity
92pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo init
93
94# Set your username
95pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo setname "ashatron"
96
97# Start web server
98pear run pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo web 3003
99```
100
101**After TRUST is granted once, you can run any command directly!**
102
103## Verify Update
104
105After updating, verify the new features are working:
106
107```bash
108# Test profile API
109curl http://ashatron.local:3003/api/profile
110
111# Should return your username if set
112# {"pubkey":"...","username":"..."}
113```
114
115**In browser:**
1161. Open http://ashatron.local:3003
1172. Click the profile button (👤)
1183. You should see the enhanced profile modal with:
119 - Prominent username display
120 - Edit button (✏️) to modify username
121 - Beautiful layout with sections
122 - Copy-to-clipboard for public keys
123
124## What's New in Version 2921
125
126### Enhanced Username Editing
127- **Better UI:** Clean, modern profile modal design
128- **Inline editing:** Click edit button to show/hide form
129- **Visual feedback:** "Saving..." state and success toasts
130- **Keyboard shortcuts:** Enter to save, Esc to cancel
131- **Character limit:** Max 32 characters with helper text
132- **Dark mode:** Full dark mode support
133
134### Previous Features (v2914)
135- Username support (CLI + API + Web UI)
136- Profile storage in Hyperbee
137- Username in post metadata
138- Timeline displays usernames
139
140## Troubleshooting
141
142### Port Already in Use
143```bash
144lsof -ti:3003 | xargs kill
145```
146
147### Can't Connect to Pear Link
148Use Method 1 (pear dump) instead - more reliable.
149
150### Data Lost
151Your data should be in `wirecore-storage/` - make sure not to delete this directory!
152
153### Old Version Still Showing
154Clear browser cache: Ctrl+Shift+R (or Cmd+Shift+R on Mac)
155
156## Version History
157
158- **2921:** Enhanced username editing UI
159- **2914:** Username support added
160- **2907:** Bug fixes (mutex, network binding)
161- **2898:** Web interface added
162
163## Support
164
165If you encounter issues, check:
1661. Server logs: `tail -f /tmp/wirecore.log` (if running with nohup)
1672. Browser console: F12 → Console tab
1683. Profile API: `curl http://ashatron.local:3003/api/profile`
pear-app/USERNAME_FEATURE.md +340 -0
……@@ -0,0 +1,340 @@
1# Username Feature Implementation
2
3**Version:** 2914
4**Date:** 2026-01-20
5**Status:** ✅ Released
6
7## Overview
8
9Wirecore now supports user-friendly usernames instead of displaying hex public keys throughout the interface. Usernames are stored in profile metadata and included in all posts.
10
11## Features
12
13### Storage
14- Username stored in Hyperbee under `profile` key
15- Persists across sessions
16- Optional (defaults to null if not set)
17- Falls back to "You" for own posts, pubkey for others
18
19### CLI Command
20
21```bash
22# Set your username
23pear run . setname "Alice"
24
25# Posts will now include your username
26pear run . post "Hello from Alice!"
27```
28
29**Output:**
30```
31✅ Username set to: Alice
32```
33
34### API Endpoints
35
36#### GET /api/profile
37Returns current user's profile information.
38
39**Response:**
40```json
41{
42 "pubkey": "20245fda1c311e44ab69042796923161efd933bed027f227ae6c3a84b9201820",
43 "username": "Alice"
44}
45```
46
47#### POST /api/profile
48Updates username.
49
50**Request:**
51```json
52{
53 "username": "Alice"
54}
55```
56
57**Response:**
58```json
59{
60 "success": true,
61 "username": "Alice"
62}
63```
64
65**Error Response (empty username):**
66```json
67{
68 "error": "Username cannot be empty"
69}
70```
71
72### Web Interface
73
741. Click the profile button (👤) in the navbar
752. Enter your username in the text field
763. Click "Save"
774. Navbar updates to show your username
78
79**Benefits:**
80- No need to remember hex pubkeys
81- Friendly display in timeline
82- Easy identification of users
83- Click to copy pubkey still available
84
85### Post Metadata
86
87Posts now include username field:
88
89```json
90{
91 "type": "post",
92 "text": "Hello world!",
93 "timestamp": 1768952256329,
94 "author": "20245fda...",
95 "username": "Alice"
96}
97```
98
99### Timeline Display
100
101Timeline shows username with fallback logic:
102
103**Own posts:**
1041. Username from post metadata (if set)
1052. "You" (if no username)
106
107**Followed posts:**
1081. Username from post metadata (if set)
1092. Follow name (if provided when following)
1103. Truncated pubkey (as last resort)
111
112**Example timeline entry:**
113```json
114{
115 "text": "Testing username feature",
116 "timestamp": 1768952256329,
117 "username": "Alice",
118 "author": "Alice",
119 "publicKey": "20245fda...",
120 "isOwn": true
121}
122```
123
124## Implementation Details
125
126### Backend Changes (index.js)
127
128**Profile initialization (line 212):**
129```javascript
130await metaBee.put('profile', { username: null })
131```
132
133**New command (lines 262-280):**
134```javascript
135async function cmdSetName(username) {
136 await loadState()
137 if (!metaBee) {
138 console.error('❌ Not initialized. Run: init')
139 return
140 }
141
142 if (!username || username.trim().length === 0) {
143 console.error('❌ Username cannot be empty')
144 return
145 }
146
147 const profileNode = await metaBee.get('profile')
148 const profile = (profileNode && profileNode.value) || {}
149 profile.username = username.trim()
150 await metaBee.put('profile', profile)
151
152 console.log('✅ Username set to:', profile.username)
153}
154```
155
156**Post with username (lines 239-260):**
157```javascript
158const profileNode = await metaBee.get('profile')
159const username = profileNode && profileNode.value && profileNode.value.username
160
161const entry = {
162 type: 'post',
163 text: message,
164 timestamp,
165 author: b4a.toString(myFeed.key, 'hex'),
166 username: username || null
167}
168```
169
170**API endpoints (lines 533-565):**
171```javascript
172case '/api/profile':
173 if (req.method === 'GET') {
174 const profileNode = await metaBee.get('profile')
175 const profile = (profileNode && profileNode.value) || {}
176
177 res.writeHead(200, { 'Content-Type': 'application/json' })
178 res.end(JSON.stringify({
179 pubkey: b4a.toString(identity.identityPublicKey, 'hex'),
180 username: profile.username || null
181 }))
182 } else if (req.method === 'POST') {
183 const body = await readBody(req)
184 const { username } = JSON.parse(body)
185
186 if (!username || username.trim().length === 0) {
187 res.writeHead(400, { 'Content-Type': 'application/json' })
188 res.end(JSON.stringify({ error: 'Username cannot be empty' }))
189 return
190 }
191
192 const profileNode = await metaBee.get('profile')
193 const profile = (profileNode && profileNode.value) || {}
194 profile.username = username.trim()
195 await metaBee.put('profile', profile)
196
197 res.writeHead(200, { 'Content-Type': 'application/json' })
198 res.end(JSON.stringify({ success: true, username: profile.username }))
199 }
200 break
201```
202
203### Frontend Changes (app-simple.js)
204
205**Profile loading (lines 32-57):**
206```javascript
207// Load profile (username)
208const profileRes = await fetch('/api/profile')
209profile = await profileRes.json()
210
211// Update navbar
212const pubkeyDisplay = document.getElementById('pubkey-display')
213if (pubkeyDisplay) {
214 const displayText = profile.username || data.pubkey.substring(0, 8) + '...'
215 pubkeyDisplay.textContent = displayText
216 pubkeyDisplay.title = profile.username ? `${profile.username} (${data.pubkey})` : data.pubkey
217}
218```
219
220**Profile modal with username input (lines 265-347):**
221```javascript
222modal.innerHTML = `
223 <div class="modal">
224 <h2>Your Profile</h2>
225 <div class="profile-info">
226 <strong>Username:</strong><br>
227 <div style="display: flex; gap: 8px; margin-top: 4px; margin-bottom: 16px;">
228 <input type="text" id="username-input" placeholder="Set your username"
229 value="${profile && profile.username ? escapeHtml(profile.username) : ''}"
230 style="flex: 1;" />
231 <button id="username-save" class="primary">Save</button>
232 </div>
233 <!-- pubkey display -->
234 </div>
235 </div>
236`
237
238// Save username
239modal.querySelector('#username-save').onclick = async () => {
240 const username = modal.querySelector('#username-input').value.trim()
241 if (!username) {
242 showToast('Username cannot be empty', 'error')
243 return
244 }
245
246 try {
247 const res = await fetch('/api/profile', {
248 method: 'POST',
249 headers: { 'Content-Type': 'application/json' },
250 body: JSON.stringify({ username })
251 })
252
253 if (!res.ok) throw new Error('Failed to save username')
254
255 showToast('Username saved!', 'success')
256 await loadCurrentUser()
257 } catch (err) {
258 console.error('Failed to save username:', err)
259 showToast('Failed to save username', 'error')
260 }
261}
262```
263
264## Testing
265
266All tests passed successfully:
267
268```bash
269# Set username via CLI
270pear run . setname "TestUser"
271# ✅ Username set to: TestUser
272
273# Get profile via API
274curl http://localhost:3003/api/profile
275# {"pubkey":"2024...","username":"TestUser"}
276
277# Post with username
278curl -X POST http://localhost:3003/api/post \
279 -H 'Content-Type: application/json' \
280 -d '{"message":"Testing username"}'
281# {"success":true}
282
283# Check timeline
284curl http://localhost:3003/api/timeline | jq '.[0]'
285# {
286# "text": "Testing username",
287# "username": "TestUser",
288# "author": "TestUser",
289# "isOwn": true
290# }
291
292# Update username via API
293curl -X POST http://localhost:3003/api/profile \
294 -H 'Content-Type: application/json' \
295 -d '{"username":"NewUser"}'
296# {"success":true,"username":"NewUser"}
297```
298
299## Migration
300
301**Existing users:**
302- No migration needed
303- Username field defaults to null
304- Posts without username fall back to "You" or pubkey
305- Users can set username at any time
306
307**New users:**
308- Initialize creates profile with `username: null`
309- Prompt on init: "💡 Set your username with: pear run . setname "YourName""
310
311## Future Enhancements
312
313Potential improvements for username feature:
314
3151. **Username validation** - Length limits, character restrictions
3162. **Username uniqueness** - DHT-based username registry
3173. **Display name vs handle** - Separate fields (@handle vs Display Name)
3184. **Username history** - Track username changes over time
3195. **Username search** - Find users by username
3206. **Username mentions** - @username linking in posts
3217. **Avatar + username** - Profile pictures alongside names
322
323## Compatibility
324
325- **Backward compatible** - Old posts without username still display correctly
326- **Forward compatible** - Future versions can add more profile fields
327- **P2P safe** - Username travels with post metadata
328- **Privacy safe** - Username is optional and user-controlled
329
330## Release Notes
331
332**Version 2914 (2026-01-20)**
333- ✅ Username storage in profile metadata
334- ✅ CLI command for setting username
335- ✅ API endpoints for profile management
336- ✅ Web UI for username configuration
337- ✅ Timeline display with username support
338- ✅ Backward compatible with existing posts
339
340**Pear Link:** `pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo`
pear-app/bridge/cli.js +6 -0
……@@ -65,6 +65,12 @@ async function main() {
6565 process.exit(0)
6666 }
6767
68 // Debug: log configuration values
69 console.log('[DEBUG] isPear:', isPear)
70 console.log('[DEBUG] Pear.config.storage:', isPear ? Pear.config.storage : 'N/A')
71 console.log('[DEBUG] storagePath:', storagePath)
72 console.log('[DEBUG] relayUrl:', relayUrl)
73
6874 // Initialize bridge (CLI runs standalone, so no existing store)
6975 const bridge = new WirecoreBridge(relayUrl, null, storagePath)
7076
pear-app/bridge/wirecore-bridge.js +39 -10
……@@ -22,6 +22,7 @@ export class WirecoreBridge {
2222 this.storagePath = storagePath
2323 this.store = existingStore // Reuse existing Corestore if provided
2424 this.metaBee = null
25 this.contentCache = null // Phase 3: content-by-hash storage
2526 this.syncBee = null // Track sync state
2627 this.relayClient = null
2728 this.myFeed = null
……@@ -58,6 +59,13 @@ export class WirecoreBridge {
5859 })
5960 await this.metaBee.ready()
6061
62 // Phase 3: Load content cache (shared with pear-app)
63 this.contentCache = new Hyperbee(this.store.get({ name: 'content-cache' }), {
64 keyEncoding: 'utf-8',
65 valueEncoding: 'utf-8'
66 })
67 await this.contentCache.ready()
68
6169 // Create sync state tracker
6270 this.syncBee = new Hyperbee(this.store.get({ name: 'bridge-sync' }), {
6371 keyEncoding: 'utf-8',
……@@ -150,16 +158,21 @@ export class WirecoreBridge {
150158 const block = await this.myFeed.get(i)
151159 const entry = JSON.parse(block.toString())
152160
153 // Extract YAML and create signature
154 const yaml = entry.text
155 const timestamp = entry.timestamp || Date.now().toString()
161 // Skip if no YAML (old format posts)
162 if (!entry.yaml) {
163 console.log(` ⏭ Skipping post ${i} (old format, no YAML)`)
164 await this.setLastSyncedIndex(i + 1)
165 continue
166 }
167
168 // Extract YAML content
169 const yaml = entry.yaml
156170
157 // Hash YAML content
158 const contentHash = crypto.hash(Buffer.from(yaml, 'utf-8')).toString('base64url')
171 // Hash YAML content (base64, not base64url - ANProto uses base64)
172 const contentHash = ANProto.hash(yaml)
159173
160 // Create signature (timestamp + contentHash)
161 const signaturePayload = timestamp + contentHash
162 const signature = ANProto.sign(signaturePayload, this.anprotoKeypair.secretKey)
174 // Sign the content hash (ANProto.sign adds timestamp internally)
175 const signature = ANProto.sign(contentHash, this.anprotoKeypair)
163176
164177 // Push to relay
165178 await this.relayClient.push(pubkey, yaml, signature, null)
……@@ -198,9 +211,25 @@ export class WirecoreBridge {
198211 const count = Array.isArray(posts) ? posts.length : 0
199212 console.log(` Received ${count} posts`)
200213
201 // TODO: Store in local Hyperbee cache for timeline integration
202 // For now, just log the posts
214 // Phase 3: Store posts in contentCache by hash
215 let stored = 0
203216 if (Array.isArray(posts)) {
217 for (const post of posts) {
218 if (post.text) {
219 // Hash the content
220 const contentHash = ANProto.hash(post.text)
221
222 // Store in contentCache (only if not already there)
223 const existing = await this.contentCache.get(contentHash)
224 if (!existing) {
225 await this.contentCache.put(contentHash, post.text)
226 stored++
227 }
228 }
229 }
230 console.log(` Stored ${stored} new posts in contentCache`)
231
232 // Log recent posts
204233 const recent = posts.slice(0, 5)
205234 console.log(` Showing ${recent.length} most recent:`)
206235 recent.forEach(post => {
pear-app/index.js +123 -10
……@@ -54,6 +54,7 @@ let store = null
5454let swarm = null
5555let myFeed = null
5656let metaBee = null
57let contentCache = null // Hyperbee for content-by-hash storage (Phase 3)
5758let userDrive = null // Hyperdrive for user files (images, etc.)
5859let identity = null // Legacy IdentityKey (will be removed in Phase 2)
5960let anprotoKeypair = null // ANProto keypair { publicKey: Buffer, secretKey: Buffer }
……@@ -79,6 +80,13 @@ async function loadState() {
7980 })
8081 await metaBee.ready()
8182
83 // Load content cache (Phase 3: content-by-hash storage)
84 contentCache = new Hyperbee(store.get({ name: 'content-cache' }), {
85 keyEncoding: 'utf-8',
86 valueEncoding: 'utf-8'
87 })
88 await contentCache.ready()
89
8290 // Check for ANProto keypair (new system) FIRST
8391 const keypairNode = await metaBee.get('anproto-keypair')
8492 if (keypairNode && keypairNode.value) {
……@@ -129,6 +137,7 @@ async function loadState() {
129137 store = null
130138 myFeed = null
131139 metaBee = null
140 contentCache = null
132141 userDrive = null
133142 identity = null
134143 return null
……@@ -325,16 +334,36 @@ async function cmdPost(message) {
325334 const username = profileNode && profileNode.value && profileNode.value.username
326335
327336 const timestamp = Date.now()
337
338 // Get previous post hash for chain
339 let previousHash = null
340 if (myFeed.length > 0) {
341 const prevBlock = await myFeed.get(myFeed.length - 1)
342 previousHash = b4a.toString(crypto.hash(prevBlock), 'base64')
343 }
344
345 // Create YAML format for relay compatibility
346 const metadata = {
347 name: username || null,
348 previous: previousHash
349 }
350 const yaml = YAMLHelper.create(metadata, message)
351
352 // Phase 3: Hash content and store in contentCache
353 const contentHash = ANProto.hash(yaml)
354 await contentCache.put(contentHash, yaml)
355
356 // Store in feed with both full content (transition) and hash
328357 const entry = {
329 type: 'post',
330 text: message,
358 yaml,
359 contentHash, // Phase 3: Add hash for Phase 4 transition
331360 timestamp,
332 author: b4a.toString(myFeed.key, 'hex'),
333361 username: username || null
334362 }
335363
336364 await myFeed.append(Buffer.from(JSON.stringify(entry)))
337365 console.log('✅ Posted:', message)
366 console.log(' Hash:', contentHash.substring(0, 16) + '...')
338367}
339368
340369/**
……@@ -1124,13 +1153,43 @@ async function handleAPI(req, res, url) {
11241153 const profileNode = await metaBee.get('profile')
11251154 const username = profileNode && profileNode.value && profileNode.value.username
11261155
1156 const timestamp = Date.now()
1157
1158 // Get previous post hash for chain
1159 let previousHash = null
1160 if (myFeed.length > 0) {
1161 const prevBlock = await myFeed.get(myFeed.length - 1)
1162 previousHash = b4a.toString(crypto.hash(prevBlock), 'base64')
1163 }
1164
1165 // Handle image if provided
1166 let imageHash = null
1167 if (imageData) {
1168 const imgBuffer = b4a.from(imageData, 'base64')
1169 imageHash = b4a.toString(crypto.hash(imgBuffer), 'base64')
1170 }
1171
1172 // Create YAML format for relay compatibility
1173 const metadata = {
1174 name: username || null,
1175 image: imageHash,
1176 previous: previousHash
1177 }
1178 const yaml = YAMLHelper.create(metadata, message)
1179
1180 // Phase 3: Hash content and store in contentCache
1181 const contentHash = ANProto.hash(yaml)
1182 await contentCache.put(contentHash, yaml)
1183
1184 // Store in feed with both full content (transition) and hash
11271185 const entry = {
1128 text: message,
1129 timestamp: Date.now(),
1186 yaml,
1187 contentHash, // Phase 3: Add hash for Phase 4 transition
1188 timestamp,
11301189 username: username || null
11311190 }
11321191
1133 // Add image data if provided (stored inline as base64)
1192 // Store image inline for P2P replication
11341193 if (imageData) {
11351194 entry.imageData = imageData
11361195 entry.imageMimeType = imageMimeType || 'image/jpeg'
……@@ -1436,6 +1495,30 @@ async function getTimelineMessages(limit) {
14361495 if (!block) continue
14371496 const entry = JSON.parse(block.toString())
14381497
1498 // Phase 3: Try contentCache first if hash available
1499 let yaml = entry.yaml
1500 if (entry.contentHash && !yaml) {
1501 const cached = await contentCache.get(entry.contentHash)
1502 if (cached) {
1503 yaml = cached.value
1504 }
1505 }
1506
1507 // If we have yaml from feed but not in cache, store it
1508 if (entry.yaml && entry.contentHash) {
1509 const cached = await contentCache.get(entry.contentHash)
1510 if (!cached) {
1511 await contentCache.put(entry.contentHash, entry.yaml)
1512 }
1513 }
1514
1515 // Parse YAML if present (new format), otherwise use text directly (old format)
1516 let text = entry.text || ''
1517 if (yaml) {
1518 const parsed = YAMLHelper.parse(yaml)
1519 text = parsed.content
1520 }
1521
14391522 // Use username from post if available, otherwise "You"
14401523 const displayName = entry.username || 'You'
14411524
……@@ -1447,10 +1530,13 @@ async function getTimelineMessages(limit) {
14471530 }
14481531
14491532 messages.push({
1450 ...entry,
1533 text,
1534 timestamp: entry.timestamp,
14511535 author: displayName,
14521536 publicKey: pubkey,
1453 isOwn: true
1537 isOwn: true,
1538 imageData: entry.imageData,
1539 imageMimeType: entry.imageMimeType
14541540 })
14551541 } catch (err) {
14561542 // Skip
……@@ -1468,14 +1554,41 @@ async function getTimelineMessages(limit) {
14681554 if (!block) continue
14691555 const entry = JSON.parse(block.toString())
14701556
1557 // Phase 3: Try contentCache first if hash available
1558 let yaml = entry.yaml
1559 if (entry.contentHash && !yaml) {
1560 const cached = await contentCache.get(entry.contentHash)
1561 if (cached) {
1562 yaml = cached.value
1563 }
1564 }
1565
1566 // If we have yaml from feed but not in cache, store it
1567 if (entry.yaml && entry.contentHash) {
1568 const cached = await contentCache.get(entry.contentHash)
1569 if (!cached) {
1570 await contentCache.put(entry.contentHash, entry.yaml)
1571 }
1572 }
1573
1574 // Parse YAML if present (new format), otherwise use text directly (old format)
1575 let text = entry.text || ''
1576 if (yaml) {
1577 const parsed = YAMLHelper.parse(yaml)
1578 text = parsed.content
1579 }
1580
14711581 // Use username from post if available, otherwise follow name or pubkey
14721582 const displayName = entry.username || name || publicKey.substring(0, 16)
14731583
14741584 messages.push({
1475 ...entry,
1585 text,
1586 timestamp: entry.timestamp,
14761587 author: displayName,
14771588 publicKey,
1478 isOwn: false
1589 isOwn: false,
1590 imageData: entry.imageData,
1591 imageMimeType: entry.imageMimeType
14791592 })
14801593 } catch (err) {
14811594 continue
pear-app/lib/relay-client.js +9 -11
……@@ -10,6 +10,7 @@
1010
1111import https from 'bare-https'
1212import http from 'bare-http1'
13import { Socket as WebSocket } from 'bare-ws'
1314
1415export class RelayClient {
1516 constructor(relayUrl = 'https://7rnx.net/relay') {
……@@ -27,16 +28,12 @@ export class RelayClient {
2728 * @returns {Promise<void>}
2829 */
2930 async push(pubkey, yaml, signature, imageData = null) {
30 // Dynamically import WebSocket (bare-ws)
31 const bareWs = await import('bare-ws')
32 const { WebSocket } = bareWs
33
3431 return new Promise((resolve, reject) => {
3532 console.log(`📤 Pushing to relay: ${this.wsUrl}`)
3633
3734 const ws = new WebSocket(this.wsUrl)
3835 const timeout = setTimeout(() => {
39 ws.close()
36 ws.destroy()
4037 reject(new Error('Relay push timeout (10s)'))
4138 }, 10000)
4239
……@@ -46,29 +43,30 @@ export class RelayClient {
4643
4744 try {
4845 // Send in order: pubkey, content, image (optional), signature
49 ws.send(pubkey)
46 // Note: bare-ws Socket is a Duplex stream, use .write() not .send()
47 ws.write(pubkey)
5048 console.log(' → Sent pubkey')
5149
52 ws.send(yaml)
50 ws.write(yaml)
5351 console.log(' → Sent content')
5452
5553 if (imageData) {
56 ws.send(imageData)
54 ws.write(imageData)
5755 console.log(' → Sent image')
5856 }
5957
60 ws.send(signature)
58 ws.write(signature)
6159 console.log(' → Sent signature')
6260
6361 // Close after 2 seconds (relay processes messages)
6462 setTimeout(() => {
65 ws.close()
63 ws.destroy()
6664 console.log('✅ Post pushed to relay')
6765 resolve()
6866 }, 2000)
6967 } catch (err) {
7068 clearTimeout(timeout)
71 ws.close()
69 ws.destroy()
7270 reject(err)
7371 }
7472 })
pear-app/public/apds-compat.js +225 -0
……@@ -0,0 +1,225 @@
1/**
2 * APDS-Compatible Identity Layer for Wirecore
3 *
4 * Matches Wiredove's apds API for browser-side identity management
5 * Uses IndexedDB for persistent storage
6 * Compatible with ANProto ed25519 keypair format (88 chars)
7 * Uses TweetNaCl for Ed25519 signing + Web Crypto API for SHA-256
8 *
9 * Note: Requires TweetNaCl to be loaded via script tag in HTML
10 */
11
12const DB_NAME = 'wirecore'
13const DB_VERSION = 1
14const STORE_NAME = 'identity'
15
16let db = null
17
18/**
19 * Initialize IndexedDB
20 */
21async function initDB() {
22 return new Promise((resolve, reject) => {
23 const request = indexedDB.open(DB_NAME, DB_VERSION)
24
25 request.onerror = () => reject(request.error)
26 request.onsuccess = () => resolve(request.result)
27
28 request.onupgradeneeded = (event) => {
29 const db = event.target.result
30 if (!db.objectStoreNames.contains(STORE_NAME)) {
31 db.createObjectStore(STORE_NAME)
32 }
33 }
34 })
35}
36
37/**
38 * Base64 encode (standard base64 - matches server b4a format)
39 */
40function base64Encode(bytes) {
41 return btoa(String.fromCharCode(...bytes))
42}
43
44/**
45 * Base64 decode
46 */
47function base64Decode(str) {
48 const binary = atob(str)
49 const bytes = new Uint8Array(binary.length)
50 for (let i = 0; i < binary.length; i++) {
51 bytes[i] = binary.charCodeAt(i)
52 }
53 return bytes
54}
55
56/**
57 * Generate ANProto-compatible ed25519 keypair (132 chars base64)
58 * Format: publicKey (44 chars) + secretKey (88 chars) = 132 total
59 */
60async function generateKeypair() {
61 // Generate keypair using TweetNaCl
62 const keypair = nacl.sign.keyPair()
63
64 // ANProto format: base64(publicKey) + base64(secretKey)
65 // publicKey: 32 bytes -> 44 chars
66 // secretKey: 64 bytes -> 88 chars (seed + public concatenated)
67 const publicKeyB64 = base64Encode(keypair.publicKey)
68 const secretKeyB64 = base64Encode(keypair.secretKey)
69
70 return publicKeyB64 + secretKeyB64
71}
72
73/**
74 * Deterministic JSON stringify (sorts keys) - matches ANProto
75 */
76function deterministicStringify(obj) {
77 if (obj === null) return 'null'
78 if (typeof obj !== 'object') return JSON.stringify(obj)
79 if (Array.isArray(obj)) return '[' + obj.map(item => deterministicStringify(item)).join(',') + ']'
80
81 const keys = Object.keys(obj).sort()
82 const pairs = keys.map(key => JSON.stringify(key) + ':' + deterministicStringify(obj[key]))
83 return '{' + pairs.join(',') + '}'
84}
85
86/**
87 * Hash data using SHA-256 (Web Crypto API)
88 */
89async function hashData(data) {
90 const encoder = new TextEncoder()
91 const dataBuffer = typeof data === 'string' ? encoder.encode(data) : data
92 const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
93 return base64Encode(new Uint8Array(hashBuffer))
94}
95
96/**
97 * Sign data with keypair (TweetNaCl - ANProto compatible)
98 */
99async function signData(data, keypair) {
100 const publicKeyB64 = keypair.substring(0, 44)
101 const secretKeyB64 = keypair.substring(44, 132)
102
103 // Convert from base64
104 const secretKey = base64Decode(secretKeyB64)
105
106 // Sign the message (TweetNaCl returns signature+message concatenated)
107 const encoder = new TextEncoder()
108 const messageBuffer = encoder.encode(data)
109 const signedMessage = nacl.sign(messageBuffer, secretKey)
110
111 // Convert to base64
112 const signedB64 = base64Encode(signedMessage)
113
114 // ANProto format: publicKey + signedMessage
115 return publicKeyB64 + signedB64
116}
117
118/**
119 * APDS-compatible API
120 */
121export const apds = {
122 /**
123 * Start APDS (initialize IndexedDB)
124 */
125 async start() {
126 // Check TweetNaCl is loaded
127 if (!window.nacl) {
128 throw new Error('TweetNaCl not loaded. Add script tag to HTML.')
129 }
130 db = await initDB()
131 console.log('Wirecore identity layer initialized (TweetNaCl ready)')
132 },
133
134 /**
135 * Generate new keypair
136 */
137 async generate() {
138 return await generateKeypair()
139 },
140
141 /**
142 * Get stored keypair
143 */
144 async keypair() {
145 if (!db) throw new Error('APDS not started')
146
147 return new Promise((resolve, reject) => {
148 const tx = db.transaction(STORE_NAME, 'readonly')
149 const store = tx.objectStore(STORE_NAME)
150 const request = store.get('keypair')
151
152 request.onsuccess = () => resolve(request.result)
153 request.onerror = () => reject(request.error)
154 })
155 },
156
157 /**
158 * Get public key only (first 44 chars of 132-char keypair)
159 */
160 async pubkey() {
161 const keypair = await this.keypair()
162 return keypair ? keypair.substring(0, 44) : null
163 },
164
165 /**
166 * Store a value
167 */
168 async put(key, value) {
169 if (!db) throw new Error('APDS not started')
170
171 return new Promise((resolve, reject) => {
172 const tx = db.transaction(STORE_NAME, 'readwrite')
173 const store = tx.objectStore(STORE_NAME)
174 store.put(value, key)
175
176 tx.oncomplete = () => resolve()
177 tx.onerror = () => reject(tx.error)
178 })
179 },
180
181 /**
182 * Get a value
183 */
184 async get(key) {
185 if (!db) throw new Error('APDS not started')
186
187 return new Promise((resolve, reject) => {
188 const tx = db.transaction(STORE_NAME, 'readonly')
189 const store = tx.objectStore(STORE_NAME)
190 const request = store.get(key)
191
192 request.onsuccess = () => resolve(request.result)
193 request.onerror = () => reject(request.error)
194 })
195 },
196
197 /**
198 * Hash content
199 */
200 async hash(data) {
201 return await hashData(data)
202 },
203
204 /**
205 * Sign content with stored keypair
206 * If data is an object, it will be stringified deterministically (matching ANProto)
207 */
208 async sign(data) {
209 const keypair = await this.keypair()
210 if (!keypair) {
211 throw new Error('No keypair - generate one first')
212 }
213
214 // If data is an object, use deterministic stringify to match server
215 const dataString = typeof data === 'object' ? deterministicStringify(data) : data
216
217 const hash = await this.hash(dataString)
218
219 // ANProto format: sign(timestamp + hash)
220 const timestamp = Date.now().toString()
221 const message = timestamp + hash
222
223 return await signData(message, keypair)
224 }
225}
pear-app/public/app.js +1585 -0
……@@ -0,0 +1,1585 @@
1/**
2 * Wirecore Web Client
3 *
4 * Frontend application for browser interface
5 */
6
7import { apds } from './apds-compat.js'
8import { autoLink, markdown } from './markdown.js'
9import { TrysteroContentTransport } from './trystero-content-transport.js'
10import { joinRoom } from './trystero-torrent.min.js'
11
12// State
13let currentUser = null
14let timeline = []
15let ws = null
16let trysteroTransport = null
17
18// API Base URL - detect if we're running under a sub-path
19const getBasePath = () => {
20 const path = window.location.pathname
21 // If path starts with /wirecore/, use that as base
22 if (path.startsWith('/wirecore/')) {
23 return window.location.origin + '/wirecore'
24 }
25 // Otherwise use origin only
26 return window.location.origin
27}
28const API_BASE = getBasePath()
29
30// Initialize app
31async function init() {
32 console.log('Initializing Wirecore web client...')
33
34 // Initialize identity layer
35 await apds.start()
36
37 // Load or generate identity
38 await initializeIdentity()
39
40 // Initialize P2P networking
41 await initializeTrystero()
42
43 // Load timeline
44 await loadTimeline()
45
46 // Connect WebSocket
47 connectWebSocket()
48
49 // Setup event listeners
50 setupEventListeners()
51
52 console.log('Wirecore initialized!')
53}
54
55// Initialize browser identity
56async function initializeIdentity() {
57 let keypair = await apds.keypair()
58
59 // Check if we have an old-format keypair (88 chars instead of 132)
60 if (keypair && keypair.length !== 132) {
61 console.log('Detected old keypair format (length ' + keypair.length + '), clearing and regenerating...')
62 // Clear old keypair
63 await apds.put('keypair', null)
64 keypair = null
65 }
66
67 if (!keypair) {
68 // First time user or migrating from old format - generate new identity
69 console.log('Generating new identity...')
70 keypair = await apds.generate()
71 await apds.put('keypair', keypair)
72
73 // Show backup modal
74 showBackupModal(keypair)
75 }
76
77 const publicKey = keypair.substring(0, 44)
78
79 currentUser = {
80 publicKey,
81 name: await apds.get('name') || 'Anonymous'
82 }
83
84 console.log('Identity loaded:', publicKey.substring(0, 8) + '...')
85
86 // Update navbar
87 const pubkeyDisplay = document.getElementById('pubkey-display')
88 if (pubkeyDisplay) {
89 pubkeyDisplay.textContent = publicKey.substring(0, 8) + '...'
90 pubkeyDisplay.title = publicKey
91 }
92}
93
94// Initialize Trystero P2P networking
95async function initializeTrystero() {
96 if (!currentUser || !currentUser.publicKey) {
97 console.error('Cannot initialize Trystero: no user identity')
98 return
99 }
100
101 try {
102 console.log('Initializing Trystero P2P networking...')
103
104 // Detect environment for relay configuration
105 const isProduction = window.location.pathname.startsWith('/wirecore/')
106
107 // Create transport with custom relay URLs and STUN servers
108 const relayUrls = isProduction ? [
109 // Production: Use only public trackers (working ones)
110 'wss://tracker.openwebtorrent.com',
111 'wss://tracker.files.fm:7073/announce'
112 ] : [
113 // Development: Try local tracker first, then public
114 'ws://localhost:8000',
115 'wss://tracker.openwebtorrent.com',
116 'wss://tracker.files.fm:7073/announce'
117 ]
118
119 trysteroTransport = new TrysteroContentTransport(apds, {
120 appId: 'wirecore-v1',
121 password: 'wirecore-p2p-2026',
122 relayUrls,
123 // STUN servers for NAT traversal (TURN servers not needed for most cases)
124 rtcConfig: {
125 iceServers: [
126 { urls: 'stun:stun.l.google.com:19302' },
127 { urls: 'stun:stun.cloudflare.com:3478' },
128 { urls: 'stun:stun1.l.google.com:19302' },
129 { urls: 'stun:stun2.l.google.com:19302' }
130 ]
131 }
132 })
133
134 // Join shared room for peer discovery (like wiredove's 'wiredovev1')
135 // All Wirecore users join the same room to find each other
136 const roomName = 'wirecorev1'
137
138 await trysteroTransport.joinRoom(roomName, joinRoom)
139
140 console.log(`Trystero: joined room ${roomName.substring(0, 8)}...`)
141 console.log(`Trystero: ${trysteroTransport.getPeerCount()} peers connected`)
142
143 // Expose to window for use in markdown.js and other modules
144 window.trysteroTransport = trysteroTransport
145
146 // Update peer count in UI periodically
147 setInterval(() => {
148 const peerCount = trysteroTransport.getPeerCount()
149 console.log(`Trystero: ${peerCount} peers connected`)
150 }, 30000) // Every 30 seconds
151 } catch (err) {
152 console.error('Failed to initialize Trystero:', err)
153 showToast('P2P networking unavailable', 'error')
154 }
155}
156
157// Show backup modal for new identity
158function showBackupModal(keypair) {
159 const publicKey = keypair.substring(0, 44)
160
161 const modal = document.createElement('div')
162 modal.className = 'modal-overlay'
163 modal.innerHTML = `
164 <div class="modal-content" style="max-width: 600px;">
165 <h2>🔑 Identity Created!</h2>
166 <p>Your identity has been generated. <strong>Save this keypair</strong> - it's your only way to access this account!</p>
167
168 <div style="margin: 20px 0;">
169 <label style="display: block; margin-bottom: 5px; font-weight: 600;">Public Key (share this):</label>
170 <input type="text" readonly value="${publicKey}" style="width: 100%; padding: 8px; font-family: monospace;" onclick="this.select()">
171 </div>
172
173 <div style="margin: 20px 0;">
174 <label style="display: block; margin-bottom: 5px; font-weight: 600;">Keypair (keep secret!):</label>
175 <textarea readonly style="width: 100%; height: 100px; padding: 8px; font-family: monospace; resize: vertical;" onclick="this.select()">${keypair}</textarea>
176 </div>
177
178 <div style="display: flex; gap: 10px; margin-top: 20px;">
179 <button onclick="downloadKeypair('${keypair}')" style="flex: 1; padding: 10px;">
180 📥 Download Backup
181 </button>
182 <button onclick="copyToClipboard('${keypair}')" style="flex: 1; padding: 10px;">
183 📋 Copy to Clipboard
184 </button>
185 </div>
186
187 <button onclick="this.closest('.modal-overlay').remove()" style="width: 100%; padding: 10px; margin-top: 10px; background: #4CAF50; color: white; border: none; cursor: pointer;">
188 ✅ I've Saved My Keypair
189 </button>
190
191 <p style="margin-top: 20px; font-size: 0.9em; color: #666;">
192 ⚠️ Without this keypair, you cannot recover your identity. Store it somewhere safe!
193 </p>
194 </div>
195 `
196
197 document.body.appendChild(modal)
198}
199
200// Download keypair as file
201window.downloadKeypair = function(keypair) {
202 const blob = new Blob([keypair], { type: 'text/plain' })
203 const url = URL.createObjectURL(blob)
204 const a = document.createElement('a')
205 a.href = url
206 a.download = `wirecore-identity-${Date.now()}.txt`
207 a.click()
208 URL.revokeObjectURL(url)
209 showToast('Keypair downloaded!', 'success')
210}
211
212// Copy to clipboard
213window.copyToClipboard = function(text) {
214 navigator.clipboard.writeText(text).then(() => {
215 showToast('Copied to clipboard!', 'success')
216 }).catch(err => {
217 console.error('Failed to copy:', err)
218 showToast('Failed to copy', 'error')
219 })
220}
221
222// Load timeline
223let timelinePagination = { offset: 0, limit: 50, hasMore: false }
224
225async function loadTimeline(append = false) {
226 try {
227 const timelineEl = document.getElementById('timeline')
228 if (!append) {
229 timelineEl.innerHTML = '<div class="loading">Loading timeline</div>'
230 timelinePagination.offset = 0
231 }
232
233 const response = await fetch(`${API_BASE}/api/timeline?limit=${timelinePagination.limit}&offset=${timelinePagination.offset}`, {
234 headers: {
235 'X-Wirecore-PublicKey': currentUser.publicKey
236 }
237 })
238
239 if (!response.ok) {
240 const error = await response.json()
241 console.error('Timeline fetch failed:', error)
242 timeline = []
243 renderTimeline()
244 showToast('Failed to load timeline: ' + (error.error || response.statusText), 'error')
245 return
246 }
247
248 const data = await response.json()
249
250 // Handle both new format (with pagination) and old format (array)
251 if (Array.isArray(data)) {
252 // Old format - backward compatibility
253 timeline = data
254 timelinePagination.hasMore = false
255 } else {
256 // New format with pagination
257 if (append) {
258 timeline = timeline.concat(data.posts)
259 } else {
260 timeline = data.posts
261 }
262 timelinePagination = data.pagination
263 }
264
265 renderTimeline()
266 } catch (err) {
267 console.error('Failed to load timeline:', err)
268 timeline = []
269 renderTimeline()
270 showToast('Failed to load timeline', 'error')
271 }
272}
273
274// Load more posts
275async function loadMore() {
276 if (!timelinePagination.hasMore) return
277 timelinePagination.offset += timelinePagination.limit
278 await loadTimeline(true)
279}
280
281// Render timeline
282async function renderTimeline() {
283 const timelineEl = document.getElementById('timeline')
284
285 if (timeline.length === 0) {
286 timelineEl.innerHTML = `
287 <div class="empty-state">
288 <h3>No posts yet</h3>
289 <p>Start posting or follow some users!</p>
290 </div>
291 `
292 return
293 }
294
295 const postsHtml = (await Promise.all(timeline.map(post => renderPost(post)))).join('')
296
297 const loadMoreButton = timelinePagination.hasMore ? `
298 <div style="text-align: center; margin: 2em 0;">
299 <button onclick="loadMore()" class="button" style="padding: 0.75em 2em;">
300 Load More
301 </button>
302 </div>
303 ` : ''
304
305 timelineEl.innerHTML = postsHtml + loadMoreButton
306}
307
308// Render a single post
309async function renderPost(post) {
310 const isOwnPost = post.author === currentUser.publicKey
311 const authorName = isOwnPost ? currentUser.name : (post.name || 'Anonymous')
312 const authorShort = post.author.substring(0, 8)
313 const avatarLetter = authorShort[0].toUpperCase()
314 const timestamp = new Date(post.timestamp).toLocaleString()
315
316 // Get profile image (try for both own and other users)
317 let avatarHtml
318 let profileImage = null
319
320 if (isOwnPost) {
321 // Load own profile image
322 profileImage = await apds.get('profile-image')
323 } else {
324 // Try to load other user's profile image (if they've shared it)
325 profileImage = await apds.get(`profile-image:${post.author}`)
326
327 // If not found locally and we have P2P, request from peers
328 if (!profileImage && window.trysteroTransport && window.trysteroTransport.hasActivePeers()) {
329 const pfpHash = `profile-image:${post.author}`
330 try {
331 console.log(`Requesting profile picture for ${authorShort}... from P2P peers`)
332 profileImage = await window.trysteroTransport.requestContent(pfpHash, { timeout: 3000 })
333 if (profileImage) {
334 // Store for future use
335 await apds.put(pfpHash, profileImage)
336 console.log(`Received profile picture for ${authorShort}... from peers`)
337 }
338 } catch (err) {
339 console.log(`P2P request timeout for profile picture ${authorShort}...`)
340 }
341 }
342 }
343
344 if (profileImage) {
345 avatarHtml = `<img src="${profileImage}" class="avatar" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`
346 } else {
347 avatarHtml = `<div class="avatar">${avatarLetter}</div>`
348 }
349
350 let contentHtml = ''
351
352 switch (post.content.type) {
353 case 'post':
354 contentHtml = `<div>${await markdown(post.content.text)}</div>`
355 break
356 case 'reply':
357 const replyTarget = post.content.target?.author ? post.content.target.author.substring(0, 8) + '...' : 'someone'
358 contentHtml = `
359 <div style="color: #999; font-size: 0.9em; margin-bottom: 0.5em;">
360 <span class="material-symbols-outlined" style="font-size: 16px; vertical-align: middle;">Reply</span>
361 Replying to ${replyTarget}
362 </div>
363 <div>${await markdown(post.content.text)}</div>
364 `
365 break
366 case 'boost':
367 // Try to find the boosted post in timeline
368 const boostedPost = post.content.target?.author ? timeline.find(p =>
369 p.author === post.content.target.author &&
370 p.sequence === post.content.target.sequence
371 ) : null
372
373 if (boostedPost && boostedPost.content.type === 'post') {
374 const boostedAuthorName = boostedPost.name || 'Anonymous'
375 const boostedAuthorShort = boostedPost.author.substring(0, 8)
376 contentHtml = `
377 <div style="margin-bottom: 0.75em; color: #10b981; font-weight: 600; display: flex; align-items: center; gap: 0.5em;">
378 <span class="material-symbols-outlined" style="font-size: 18px;">Repeat</span>
379 <span>Boosted</span>
380 </div>
381 <div style="padding-left: 1em; border-left: 3px solid #10b981; margin-left: 0.5em;">
382 <div style="margin-bottom: 0.5em;">
383 <a href="#${boostedPost.author}" onclick="event.stopPropagation()" style="font-weight: 600; color: #333; text-decoration: none;">${escapeHtml(boostedAuthorName)}</a>
384 <span style="color: #999; font-size: 0.85em; margin-left: 0.5em;">${boostedAuthorShort}...</span>
385 </div>
386 <div>${await markdown(boostedPost.content.text)}</div>
387 </div>
388 `
389 } else {
390 contentHtml = `
391 <div style="color: #10b981; font-weight: 600;">
392 <span class="material-symbols-outlined" style="font-size: 18px; vertical-align: middle;">Repeat</span>
393 Boosted ${post.content.target.author.substring(0, 8)}...'s post
394 <span style="color: #999; font-weight: normal; font-size: 0.9em;">(content not loaded)</span>
395 </div>
396 `
397 }
398 break
399 case 'like':
400 contentHtml = `
401 <div style="color: #ef4444; font-weight: 600;">
402 <span class="material-symbols-outlined" style="font-size: 18px; vertical-align: middle;">Favorite</span>
403 Liked ${post.content.target.author.substring(0, 8)}...'s post
404 </div>
405 `
406 break
407 }
408
409 return `
410 <div class="message message-clickable" data-author="${post.author}" data-sequence="${post.sequence}" onclick="window.location.hash='msg:${post.author}@${post.sequence}'">
411 <div class="message-header">
412 <a href="#${post.author}" class="avatar-link" onclick="event.stopPropagation()">
413 ${avatarHtml}
414 </a>
415 <div class="message-meta">
416 <div>
417 <a href="#${post.author}" class="message-author" onclick="event.stopPropagation()">${escapeHtml(authorName)}</a>
418 <span class="message-pubkey">${authorShort}...</span>
419 </div>
420 <div class="message-time">${timestamp}</div>
421 </div>
422 </div>
423 <div class="message-content">
424 ${contentHtml}
425 </div>
426 <div class="message-actions" onclick="event.stopPropagation()">
427 <button class="action-btn" onclick="replyToPost('${post.author}', ${post.sequence})">
428 <span class="material-symbols-outlined">Reply</span>
429 Reply
430 </button>
431 <button class="action-btn" onclick="boostPost('${post.author}', ${post.sequence})">
432 <span class="material-symbols-outlined">Repeat</span>
433 Boost
434 </button>
435 <button class="action-btn" onclick="likePost('${post.author}', ${post.sequence})">
436 <span class="material-symbols-outlined">Favorite</span>
437 Like
438 </button>
439 <button class="action-btn" onclick="sharePost('${post.author}', ${post.sequence})" title="Copy link to this post">
440 <span class="material-symbols-outlined">Share</span>
441 </button>
442 </div>
443 </div>
444 `
445}
446
447// Escape HTML to prevent XSS
448function escapeHtml(text) {
449 const div = document.createElement('div')
450 div.textContent = text
451 return div.innerHTML
452}
453
454// WebSocket reconnection state
455let wsReconnectAttempts = 0
456let wsReconnectTimeout = null
457const WS_MAX_RECONNECT_DELAY = 30000 // Max 30 seconds
458
459// Connect WebSocket for real-time updates
460function connectWebSocket() {
461 // Clear any pending reconnection
462 if (wsReconnectTimeout) {
463 clearTimeout(wsReconnectTimeout)
464 wsReconnectTimeout = null
465 }
466
467 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
468 const host = window.location.host
469
470 // Determine WebSocket URL based on environment
471 let wsUrl
472 if (window.location.pathname.startsWith('/wirecore/')) {
473 // Production: behind Apache proxy at /wirecore/
474 wsUrl = `${protocol}//${host}/wirecore/`
475 } else {
476 // Development: direct connection to server
477 wsUrl = `${protocol}//${host}`
478 }
479
480 // Close existing connection if any
481 if (ws && ws.readyState === WebSocket.OPEN) {
482 ws.close()
483 }
484
485 ws = new WebSocket(wsUrl)
486
487 ws.onopen = () => {
488 console.log('WebSocket connected')
489 wsReconnectAttempts = 0 // Reset reconnection counter
490 showToast('Connected', 'success')
491 }
492
493 ws.onmessage = (event) => {
494 const message = JSON.parse(event.data)
495
496 if (message.type === 'post') {
497 // Add new post to timeline
498 timeline.unshift(message.data)
499 renderTimeline()
500 showToast('New post!')
501 } else if (message.type === 'follow') {
502 showToast(`Now following ${message.data.name}`)
503 }
504 }
505
506 ws.onclose = (event) => {
507 console.log('WebSocket disconnected', event.code, event.reason)
508
509 // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max)
510 wsReconnectAttempts++
511 const delay = Math.min(1000 * Math.pow(2, wsReconnectAttempts - 1), WS_MAX_RECONNECT_DELAY)
512
513 console.log(`Reconnecting in ${delay/1000}s (attempt ${wsReconnectAttempts})...`)
514 wsReconnectTimeout = setTimeout(connectWebSocket, delay)
515 }
516
517 ws.onerror = (err) => {
518 console.error('WebSocket error:', err)
519 }
520}
521
522// Setup event listeners
523function setupEventListeners() {
524 const composeBtn = document.getElementById('compose-btn')
525 const refreshBtn = document.getElementById('refresh-btn')
526 const followBtn = document.getElementById('follow-btn')
527 const searchBtn = document.getElementById('search-btn')
528 const profileBtn = document.getElementById('profile-btn')
529
530 console.log('Setting up event listeners...')
531 console.log('Compose button:', composeBtn)
532 console.log('Refresh button:', refreshBtn)
533 console.log('Follow button:', followBtn)
534 console.log('Search button:', searchBtn)
535 console.log('Profile button:', profileBtn)
536
537 if (composeBtn) {
538 composeBtn.addEventListener('click', () => {
539 console.log('Compose button clicked!')
540 showComposeModal()
541 })
542 } else {
543 console.error('Compose button not found!')
544 }
545
546 if (refreshBtn) {
547 refreshBtn.addEventListener('click', loadTimeline)
548 }
549
550 if (followBtn) {
551 followBtn.addEventListener('click', showFollowModal)
552 }
553
554 if (searchBtn) {
555 searchBtn.addEventListener('click', showSearchModal)
556 }
557
558 if (profileBtn) {
559 profileBtn.addEventListener('click', showProfileModal)
560 }
561}
562
563// Show compose modal
564function showComposeModal(replyTo = null) {
565 console.log('showComposeModal called with replyTo:', replyTo)
566 const modal = document.createElement('div')
567 modal.className = 'modal-overlay'
568 modal.onclick = (e) => {
569 if (e.target === modal) {
570 modal.remove()
571 }
572 }
573
574 const title = replyTo ? 'Reply' : 'New Post'
575 const placeholder = replyTo ? 'Write your reply...' : 'What\'s on your mind?'
576
577 modal.innerHTML = `
578 <div class="modal-content">
579 <div class="modal-header">
580 <h2 class="modal-title">${title}</h2>
581 <button class="close-btn" onclick="this.closest('.modal-overlay').remove()">×</button>
582 </div>
583 <form id="compose-form">
584 ${replyTo ? `<p style="color: #999; margin-bottom: 1em;">Replying to ${replyTo.author.substring(0, 8)}...</p>` : ''}
585 <textarea id="compose-text" placeholder="${placeholder}" required></textarea>
586 <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 0.5em;">
587 <div>
588 <button type="button" id="image-upload-btn" class="icon-btn" title="Upload image" style="background: none; border: 1px solid #e4e4e4;">
589 <span class="material-symbols-outlined">Image</span>
590 </button>
591 <input type="file" id="post-image-upload" accept="image/*" style="display: none;">
592 </div>
593 <div style="display: flex; gap: 0.5em;">
594 <button type="button" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
595 <button type="submit">Post</button>
596 </div>
597 </div>
598 </form>
599 </div>
600 `
601
602 document.body.appendChild(modal)
603
604 // Focus textarea
605 setTimeout(() => document.getElementById('compose-text').focus(), 100)
606
607 // Handle image upload button
608 const imageUploadBtn = document.getElementById('image-upload-btn')
609 const imageUploadInput = document.getElementById('post-image-upload')
610 const composeTextarea = document.getElementById('compose-text')
611
612 imageUploadBtn.addEventListener('click', () => {
613 imageUploadInput.click()
614 })
615
616 imageUploadInput.addEventListener('change', async (e) => {
617 const file = e.target.files[0]
618 if (!file) return
619
620 const reader = new FileReader()
621
622 reader.onload = async (e) => {
623 try {
624 // Hash the image data
625 const imageData = e.target.result
626 const hash = await apds.hash(imageData)
627
628 // Store image in IndexedDB
629 await apds.put(`post-image:${hash}`, imageData)
630
631 // Insert markdown image syntax into textarea
632 const imageMarkdown = `![](${hash})`
633 const currentText = composeTextarea.value
634
635 if (currentText) {
636 composeTextarea.value = currentText + '\n' + imageMarkdown
637 } else {
638 composeTextarea.value = imageMarkdown
639 }
640
641 showToast('Image uploaded! Preview after posting.', 'success')
642
643 // Clear file input
644 imageUploadInput.value = ''
645 } catch (err) {
646 console.error('Image upload error:', err)
647 showToast('Failed to upload image', 'error')
648 }
649 }
650
651 reader.readAsDataURL(file)
652 })
653
654 // Handle form submit
655 document.getElementById('compose-form').onsubmit = async (e) => {
656 e.preventDefault()
657
658 const text = document.getElementById('compose-text').value
659
660 try {
661 const submitBtn = e.target.querySelector('button[type="submit"]')
662 submitBtn.disabled = true
663 submitBtn.textContent = 'Posting...'
664
665 if (replyTo) {
666 // Sign reply content locally (apds.sign will stringify deterministically)
667 const content = {
668 type: 'reply',
669 target: { author: replyTo.author, sequence: replyTo.sequence },
670 text
671 }
672 const signature = await apds.sign(content)
673
674 await fetch(`${API_BASE}/api/post`, {
675 method: 'POST',
676 headers: {
677 'Content-Type': 'application/json',
678 'X-Wirecore-PublicKey': currentUser.publicKey
679 },
680 body: JSON.stringify({
681 signature,
682 content
683 })
684 })
685 showToast('Reply posted!', 'success')
686 } else {
687 // Sign post content locally (apds.sign will stringify deterministically)
688 const content = { type: 'post', text }
689 const signature = await apds.sign(content)
690
691 await fetch(`${API_BASE}/api/post`, {
692 method: 'POST',
693 headers: {
694 'Content-Type': 'application/json',
695 'X-Wirecore-PublicKey': currentUser.publicKey
696 },
697 body: JSON.stringify({
698 signature,
699 content
700 })
701 })
702 showToast('Post published!', 'success')
703 }
704
705 modal.remove()
706 await loadTimeline()
707 } catch (err) {
708 console.error('Failed to post:', err)
709 showToast('Failed to post', 'error')
710 e.target.querySelector('button[type="submit"]').disabled = false
711 e.target.querySelector('button[type="submit"]').textContent = 'Post'
712 }
713 }
714}
715
716// Reply to post
717window.replyToPost = function(author, sequence) {
718 showComposeModal({ author, sequence })
719}
720
721// Boost post
722window.boostPost = async function(author, sequence) {
723 try {
724 await fetch(`${API_BASE}/api/boost`, {
725 method: 'POST',
726 headers: {
727 'Content-Type': 'application/json',
728 'X-Wirecore-PublicKey': currentUser.publicKey
729 },
730 body: JSON.stringify({ author, sequence })
731 })
732 showToast('Boosted!', 'success')
733 await loadTimeline()
734 } catch (err) {
735 console.error('Failed to boost:', err)
736 showToast('Failed to boost', 'error')
737 }
738}
739
740// Like post
741window.likePost = async function(author, sequence) {
742 try {
743 await fetch(`${API_BASE}/api/like`, {
744 method: 'POST',
745 headers: {
746 'Content-Type': 'application/json',
747 'X-Wirecore-PublicKey': currentUser.publicKey
748 },
749 body: JSON.stringify({ author, sequence })
750 })
751 showToast('Liked!', 'success')
752 await loadTimeline()
753 } catch (err) {
754 console.error('Failed to like:', err)
755 showToast('Failed to like', 'error')
756 }
757}
758
759// Show follow modal
760async function showFollowModal() {
761 // Get current following list
762 let following = []
763 try {
764 const res = await fetch(`${API_BASE}/api/following`, {
765 headers: {
766 'X-Wirecore-PublicKey': currentUser.publicKey
767 }
768 })
769 if (res.ok) {
770 const data = await res.json()
771 following = data.following || []
772 }
773 } catch (err) {
774 console.error('Failed to load following list:', err)
775 }
776
777 const alreadyFollowingClode = following.some(f => f.publicKey === 'PV5UJBmgbs9Xq2D3Qttl8Zz7Xq59Pr9SsPtxxxRumzM=')
778
779 const modal = document.createElement('div')
780 modal.className = 'modal-overlay'
781 modal.onclick = (e) => {
782 if (e.target === modal) modal.remove()
783 }
784
785 const followingListHTML = following.length > 0
786 ? `<div style="margin-bottom: 1.5em; padding: 1em; background: #f9fafb; border-radius: 6px;">
787 <p style="font-weight: 600; margin-bottom: 0.75em; color: #374151;">Following (${following.length})</p>
788 ${following.map(f => `
789 <div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5em 0; border-bottom: 1px solid #e5e7eb;">
790 <div>
791 <div style="font-weight: 500;">${f.name}</div>
792 <div style="font-size: 0.75em; color: #6b7280; font-family: monospace;">${f.publicKey.substring(0, 20)}...</div>
793 </div>
794 </div>
795 `).join('')}
796 </div>`
797 : `<div style="margin-bottom: 1.5em; padding: 1em; background: #f9fafb; border-radius: 6px; color: #6b7280; text-align: center;">
798 Not following anyone yet. Start by following clode below!
799 </div>`
800
801 modal.innerHTML = `
802 <div class="modal-content">
803 <div class="modal-header">
804 <h2 class="modal-title">Follow User</h2>
805 <button class="close-btn" onclick="this.closest('.modal-overlay').remove()">×</button>
806 </div>
807
808 ${followingListHTML}
809
810 <div style="margin-bottom: 1.5em; padding: 1em; background: #eff6ff; border-radius: 6px; border-left: 3px solid #3b82f6;">
811 <p style="font-weight: 600; margin-bottom: 0.25em;">Quick Follow: clode</p>
812 <p style="font-size: 0.85em; color: #6b7280; margin-bottom: 0.75em;">Development updates from the Wirecore team</p>
813 <button type="button" id="follow-clode-btn"
814 ${alreadyFollowingClode ? 'disabled' : ''}
815 style="width: 100%; padding: 0.75em; background: ${alreadyFollowingClode ? '#9ca3af' : '#3b82f6'}; color: white; border: none; border-radius: 6px; cursor: ${alreadyFollowingClode ? 'not-allowed' : 'pointer'}; font-weight: 500;">
816 ${alreadyFollowingClode ? '✓ Already Following' : 'Follow clode'}
817 </button>
818 </div>
819
820 <form id="follow-form">
821 <p style="font-weight: 600; margin-bottom: 1em; color: #374151;">Or Follow Custom User</p>
822 <div style="margin-bottom: 1em;">
823 <label style="display: block; margin-bottom: 0.5em; font-weight: 600;">Public Key (ANProto)</label>
824 <input type="text" id="follow-publickey" placeholder="PV5UJBmgbs9Xq2D3Qttl8Zz7..." required
825 style="width: 100%; padding: 0.5em; font-family: 'Roboto Mono', monospace; font-size: 0.9em;">
826 <small style="color: #999;">Example: clode's key</small>
827 </div>
828 <div style="margin-bottom: 1em;">
829 <label style="display: block; margin-bottom: 0.5em; font-weight: 600;">Feed Key (Hypercore)</label>
830 <input type="text" id="follow-feedkey" placeholder="9dNgEVvB72V053D0D7hi..." required
831 style="width: 100%; padding: 0.5em; font-family: 'Roboto Mono', monospace; font-size: 0.9em;">
832 <small style="color: #999;">Base64 encoded Hypercore key</small>
833 </div>
834 <div style="margin-bottom: 1.5em;">
835 <label style="display: block; margin-bottom: 0.5em; font-weight: 600;">Name (optional)</label>
836 <input type="text" id="follow-name" placeholder="Anonymous"
837 style="width: 100%; padding: 0.5em;">
838 </div>
839 <div style="display: flex; justify-content: flex-end; gap: 0.5em;">
840 <button type="button" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
841 <button type="submit">Follow</button>
842 </div>
843 </form>
844 </div>
845 `
846
847 document.body.appendChild(modal)
848
849 // Handle clode follow button
850 const clodeBtn = document.getElementById('follow-clode-btn')
851 if (clodeBtn && !alreadyFollowingClode) {
852 clodeBtn.onclick = async () => {
853 clodeBtn.disabled = true
854 clodeBtn.textContent = 'Following...'
855 try {
856 await followClode()
857 modal.remove()
858 } catch (err) {
859 clodeBtn.disabled = false
860 clodeBtn.textContent = 'Follow clode'
861 }
862 }
863 }
864
865 // Handle form submit
866 document.getElementById('follow-form').onsubmit = async (e) => {
867 e.preventDefault()
868
869 const publicKey = document.getElementById('follow-publickey').value.trim()
870 const feedKey = document.getElementById('follow-feedkey').value.trim()
871 const name = document.getElementById('follow-name').value.trim() || 'Anonymous'
872
873 try {
874 const submitBtn = e.target.querySelector('button[type="submit"]')
875 submitBtn.disabled = true
876 submitBtn.textContent = 'Following...'
877
878 const res = await fetch(`${API_BASE}/api/follow`, {
879 method: 'POST',
880 headers: {
881 'Content-Type': 'application/json',
882 'X-Wirecore-PublicKey': currentUser.publicKey
883 },
884 body: JSON.stringify({ publicKey, feedKey, name })
885 })
886
887 if (!res.ok) {
888 let errorMsg = 'Failed to follow'
889 try {
890 const error = await res.json()
891 errorMsg = error.error || errorMsg
892 } catch (e) {
893 // Response wasn't JSON, use status text
894 errorMsg = res.statusText || errorMsg
895 }
896 throw new Error(errorMsg)
897 }
898
899 showToast(`Now following ${name}!`, 'success')
900 modal.remove()
901
902 // Reload timeline to show new posts
903 setTimeout(loadTimeline, 1000)
904 } catch (error) {
905 console.error('Follow error:', error)
906 showToast('Failed to follow: ' + error.message, 'error')
907 const submitBtn = e.target.querySelector('button[type="submit"]')
908 submitBtn.disabled = false
909 submitBtn.textContent = 'Follow'
910 }
911 }
912}
913
914// Quick follow clode
915window.followClode = async function() {
916 console.log('followClode called, currentUser:', currentUser)
917
918 if (!currentUser || !currentUser.publicKey) {
919 showToast('Please wait for identity to load', 'error')
920 return
921 }
922
923 try {
924 const res = await fetch(`${API_BASE}/api/follow`, {
925 method: 'POST',
926 headers: {
927 'Content-Type': 'application/json',
928 'X-Wirecore-PublicKey': currentUser.publicKey
929 },
930 body: JSON.stringify({
931 publicKey: 'PV5UJBmgbs9Xq2D3Qttl8Zz7Xq59Pr9SsPtxxxRumzM=',
932 feedKey: '9dNgEVvB72V053D0D7himBGJBqPyr4rJyZJYRgcWivA=',
933 name: 'clode'
934 })
935 })
936
937 console.log('Follow clode response:', res.status, res.statusText)
938
939 if (!res.ok) {
940 let errorMsg = 'Failed to follow'
941 try {
942 const error = await res.json()
943 console.error('Follow error response:', error)
944 errorMsg = error.error || errorMsg
945 } catch (e) {
946 // Response wasn't JSON, use status text
947 errorMsg = res.statusText || errorMsg
948 }
949 throw new Error(errorMsg)
950 }
951
952 showToast('Now following clode!', 'success')
953 document.querySelector('.modal-overlay')?.remove()
954
955 // Reload timeline to show clode's posts
956 setTimeout(loadTimeline, 1000)
957 } catch (error) {
958 console.error('Follow clode error:', error)
959 showToast('Failed to follow clode: ' + error.message, 'error')
960 throw error // Re-throw so button handler can catch it
961 }
962}
963
964// Show profile modal
965async function showProfileModal() {
966 const keypair = await apds.keypair()
967 const name = await apds.get('name') || 'Anonymous'
968 const bio = await apds.get('bio') || ''
969 const profileImage = await apds.get('profile-image')
970
971 const modal = document.createElement('div')
972 modal.className = 'modal-overlay'
973 modal.onclick = (e) => {
974 if (e.target === modal) modal.remove()
975 }
976
977 modal.innerHTML = `
978 <div class="modal-content">
979 <div class="modal-header">
980 <h2 class="modal-title">Your Profile</h2>
981 <button class="close-btn" onclick="this.closest('.modal-overlay').remove()">×</button>
982 </div>
983 <div>
984 <div style="text-align: center; margin-bottom: 1.5em;">
985 <div style="position: relative; display: inline-block;">
986 ${profileImage ? `
987 <img id="profile-avatar-preview" src="${profileImage}" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover; cursor: pointer; border: 3px solid #eee;" onclick="document.getElementById('avatar-upload').click()">
988 ` : `
989 <div id="profile-avatar-preview" style="width: 120px; height: 120px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 3em; cursor: pointer; border: 3px solid #eee;" onclick="document.getElementById('avatar-upload').click()">
990 ${currentUser.publicKey[0].toUpperCase()}
991 </div>
992 `}
993 <div style="position: absolute; bottom: 0; right: 0; background: #667eea; color: white; width: 35px; height: 35px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; border: 3px solid white;" onclick="document.getElementById('avatar-upload').click()">
994 <span class="material-symbols-outlined" style="font-size: 20px;">Photo_Camera</span>
995 </div>
996 </div>
997 <input type="file" id="avatar-upload" accept="image/*" style="display: none;" onchange="handleAvatarUpload(event)">
998 <p style="margin-top: 0.5em; font-size: 0.85em; color: #999;">Click to upload profile picture</p>
999 </div>
1000
1001 <label>Public Key</label>
1002 <input type="text" value="${currentUser.publicKey}" readonly onclick="this.select()">
1003
1004 <label>Display Name</label>
1005 <input type="text" id="profile-name" value="${name}" placeholder="Your name">
1006
1007 <label>Bio</label>
1008 <textarea id="profile-bio" placeholder="Tell us about yourself">${bio}</textarea>
1009
1010 <div style="margin: 20px 0; padding: 15px; background: #f0f0f0; border-radius: 4px;">
1011 <label style="display: block; margin-bottom: 5px; font-weight: 600;">Backup Keypair</label>
1012 <p style="font-size: 0.9em; color: #666; margin-bottom: 10px;">Save your keypair for account recovery</p>
1013 <div style="display: flex; gap: 10px;">
1014 <button onclick="downloadKeypair('${keypair}')" style="flex: 1;">📥 Download</button>
1015 <button onclick="copyToClipboard('${keypair}')" style="flex: 1;">📋 Copy</button>
1016 </div>
1017 </div>
1018
1019 <div style="margin: 20px 0; padding: 15px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;">
1020 <label style="display: block; margin-bottom: 5px; font-weight: 600; color: #856404;">Danger Zone</label>
1021 <p style="font-size: 0.9em; color: #856404; margin-bottom: 10px;">This will delete your identity permanently</p>
1022 <button onclick="resetIdentity()" style="width: 100%; padding: 8px; background: #dc3545; color: white; border: none; cursor: pointer; border-radius: 4px;">🗑️ Reset Identity</button>
1023 </div>
1024
1025 <div style="display: flex; justify-content: flex-end; gap: 0.5em;">
1026 <button onclick="this.closest('.modal-overlay').remove()">Cancel</button>
1027 <button onclick="updateProfile()">Save Profile</button>
1028 </div>
1029 </div>
1030 </div>
1031 `
1032
1033 document.body.appendChild(modal)
1034}
1035
1036// Handle avatar upload
1037window.handleAvatarUpload = function(event) {
1038 const file = event.target.files[0]
1039 if (!file) return
1040
1041 const reader = new FileReader()
1042
1043 reader.onload = (e) => {
1044 const canvas = document.createElement('canvas')
1045 const ctx = canvas.getContext('2d')
1046 const img = new Image()
1047
1048 img.onload = () => {
1049 const size = 256
1050 let cropWidth, cropHeight, offsetX = 0, offsetY = 0
1051
1052 // Calculate crop dimensions (center square crop)
1053 if (img.width > img.height) {
1054 cropHeight = img.height
1055 cropWidth = img.height
1056 offsetX = (img.width - img.height) / 2
1057 } else {
1058 cropWidth = img.width
1059 cropHeight = img.width
1060 offsetY = (img.height - img.width) / 2
1061 }
1062
1063 // Resize to 256x256
1064 canvas.width = size
1065 canvas.height = size
1066 ctx.drawImage(img, offsetX, offsetY, cropWidth, cropHeight, 0, 0, size, size)
1067
1068 const croppedImage = canvas.toDataURL('image/jpeg', 0.85)
1069
1070 // Update preview
1071 const preview = document.getElementById('profile-avatar-preview')
1072 if (preview.tagName === 'IMG') {
1073 preview.src = croppedImage
1074 } else {
1075 // Replace div with img
1076 const newPreview = document.createElement('img')
1077 newPreview.id = 'profile-avatar-preview'
1078 newPreview.src = croppedImage
1079 newPreview.style = 'width: 120px; height: 120px; border-radius: 50%; object-fit: cover; cursor: pointer; border: 3px solid #eee;'
1080 newPreview.onclick = () => document.getElementById('avatar-upload').click()
1081 preview.replaceWith(newPreview)
1082 }
1083
1084 // Store in IndexedDB
1085 apds.put('profile-image', croppedImage)
1086 showToast('Profile picture uploaded!', 'success')
1087 }
1088
1089 img.src = e.target.result
1090 }
1091
1092 reader.readAsDataURL(file)
1093}
1094
1095// Update profile
1096window.updateProfile = async function() {
1097 const name = document.getElementById('profile-name').value
1098 const bio = document.getElementById('profile-bio').value
1099
1100 try {
1101 // Save to IndexedDB
1102 await apds.put('name', name)
1103 await apds.put('bio', bio)
1104
1105 // Update current user
1106 currentUser.name = name
1107
1108 // Update server-side profile (important for YAML metadata in posts)
1109 try {
1110 const response = await fetch('/api/profile', {
1111 method: 'POST',
1112 headers: {
1113 'Content-Type': 'application/json',
1114 'x-wirecore-publickey': currentUser.publicKey
1115 },
1116 body: JSON.stringify({ name, bio, image: currentUser.imageHash })
1117 })
1118
1119 if (!response.ok) {
1120 console.error('Server profile update failed:', response.status)
1121 } else {
1122 console.log('✓ Server-side profile updated')
1123 }
1124 } catch (serverErr) {
1125 console.error('Failed to update server-side profile:', serverErr)
1126 // Don't fail the whole operation if server update fails
1127 }
1128
1129 showToast('Profile updated!', 'success')
1130 document.querySelector('.modal-overlay').remove()
1131
1132 // Re-render timeline to show new username
1133 renderTimeline()
1134 } catch (err) {
1135 console.error('Failed to update profile:', err)
1136 showToast('Failed to update profile', 'error')
1137 }
1138}
1139
1140// Reset identity (delete all data and reload)
1141window.resetIdentity = async function() {
1142 if (!confirm('⚠️ This will permanently delete your identity and all data. Make sure you have backed up your keypair!\n\nAre you sure you want to continue?')) {
1143 return
1144 }
1145
1146 try {
1147 // Clear all IndexedDB data
1148 await apds.put('keypair', null)
1149 await apds.put('name', null)
1150 await apds.put('bio', null)
1151
1152 showToast('Identity reset. Reloading...', 'success')
1153
1154 // Reload page after a short delay
1155 setTimeout(() => {
1156 window.location.reload()
1157 }, 1000)
1158 } catch (err) {
1159 console.error('Failed to reset identity:', err)
1160 showToast('Failed to reset identity', 'error')
1161 }
1162}
1163
1164// Show toast notification
1165function showToast(message, type = 'info') {
1166 const container = document.getElementById('toast-container')
1167
1168 const toast = document.createElement('div')
1169 toast.className = `toast ${type}`
1170 toast.textContent = message
1171
1172 container.appendChild(toast)
1173
1174 // Auto-remove after 3 seconds
1175 setTimeout(() => {
1176 toast.style.animation = 'slidein 0.3s reverse'
1177 setTimeout(() => toast.remove(), 300)
1178 }, 3000)
1179}
1180
1181// Open image lightbox
1182window.openImageLightbox = function(imgElement) {
1183 const lightbox = document.createElement('div')
1184 lightbox.className = 'image-lightbox'
1185
1186 const img = document.createElement('img')
1187 img.src = imgElement.src
1188 img.alt = imgElement.alt
1189
1190 const closeBtn = document.createElement('button')
1191 closeBtn.className = 'image-lightbox-close'
1192 closeBtn.innerHTML = '×'
1193 closeBtn.onclick = (e) => {
1194 e.stopPropagation()
1195 lightbox.remove()
1196 }
1197
1198 lightbox.onclick = (e) => {
1199 e.stopPropagation() // Prevent event from bubbling to message card
1200 lightbox.remove()
1201 }
1202 img.onclick = (e) => e.stopPropagation() // Prevent closing when clicking image
1203
1204 lightbox.appendChild(img)
1205 lightbox.appendChild(closeBtn)
1206 document.body.appendChild(lightbox)
1207
1208 // Close on Escape key
1209 const handleEscape = (e) => {
1210 if (e.key === 'Escape') {
1211 lightbox.remove()
1212 document.removeEventListener('keydown', handleEscape)
1213 }
1214 }
1215 document.addEventListener('keydown', handleEscape)
1216}
1217
1218// Hash-based routing
1219function handleHashChange() {
1220 const hash = window.location.hash.substring(1) // Remove #
1221
1222 if (!hash) {
1223 return
1224 }
1225
1226 // Check if it's a message permalink (msg:author@sequence)
1227 if (hash.startsWith('msg:')) {
1228 const parts = hash.substring(4).split('@')
1229 if (parts.length === 2) {
1230 const [author, sequence] = parts
1231 showMessageContext(author, sequence)
1232 return
1233 }
1234 }
1235
1236 // Check if it's a public key (44 chars base64)
1237 if (hash.length === 44 && !hash.startsWith('?')) {
1238 showUserProfile(hash)
1239 } else if (hash.startsWith('?')) {
1240 // Hashtag search
1241 const hashtag = hash.substring(1)
1242 showHashtagTimeline(hashtag)
1243 }
1244}
1245
1246// Show user profile modal
1247async function showUserProfile(publicKey) {
1248 const shortKey = publicKey.substring(0, 8) + '...'
1249
1250 // Get user info
1251 let userName = 'Anonymous'
1252 let userFeedKey = null
1253
1254 // Check if this user is in our following list
1255 try {
1256 const res = await fetch(`${API_BASE}/api/discover/${publicKey}`)
1257 if (res.ok) {
1258 const data = await res.json()
1259 userFeedKey = data.feedKey
1260 }
1261 } catch (err) {
1262 console.error('Failed to discover user:', err)
1263 }
1264
1265 const isOwnProfile = currentUser && currentUser.publicKey === publicKey
1266
1267 const modal = document.createElement('div')
1268 modal.className = 'modal-overlay'
1269 modal.onclick = (e) => {
1270 if (e.target === modal) {
1271 modal.remove()
1272 window.location.hash = '' // Clear hash
1273 }
1274 }
1275
1276 modal.innerHTML = `
1277 <div class="modal-content" onclick="event.stopPropagation()">
1278 <div class="modal-header">
1279 <h2 class="modal-title">User Profile</h2>
1280 <button class="close-btn" onclick="this.closest('.modal-overlay').remove(); window.location.hash=''">×</button>
1281 </div>
1282
1283 <div style="text-align: center; margin: 1.5em 0;">
1284 <div class="avatar" style="width: 80px; height: 80px; font-size: 2em; margin: 0 auto 1em;">
1285 ${publicKey[0].toUpperCase()}
1286 </div>
1287 <h3 style="margin: 0.5em 0;">${userName}</h3>
1288 <p style="font-family: 'Roboto Mono', monospace; font-size: 0.85em; color: #999; word-break: break-all;">
1289 ${publicKey}
1290 </p>
1291 </div>
1292
1293 ${isOwnProfile ? `
1294 <div style="text-align: center; padding: 1em; background: #f0f0f0; border-radius: 6px; margin-bottom: 1em;">
1295 <p style="color: #666;">This is your profile</p>
1296 </div>
1297 ` : `
1298 <div style="text-align: center;">
1299 <button onclick="followUserFromProfile('${publicKey}', '${userFeedKey || ''}', '${userName}')" style="padding: 0.75em 2em;">
1300 Follow ${userName}
1301 </button>
1302 </div>
1303 `}
1304 </div>
1305 `
1306
1307 document.body.appendChild(modal)
1308}
1309
1310// Follow user from profile modal
1311window.followUserFromProfile = async function(publicKey, feedKey, name) {
1312 if (!currentUser || !currentUser.publicKey) {
1313 showToast('Please wait for identity to load', 'error')
1314 return
1315 }
1316
1317 try {
1318 const res = await fetch(`${API_BASE}/api/follow`, {
1319 method: 'POST',
1320 headers: {
1321 'Content-Type': 'application/json',
1322 'X-Wirecore-PublicKey': currentUser.publicKey
1323 },
1324 body: JSON.stringify({
1325 publicKey,
1326 feedKey: feedKey || undefined,
1327 name: name || 'Anonymous'
1328 })
1329 })
1330
1331 if (!res.ok) {
1332 const error = await res.json()
1333 throw new Error(error.error || 'Failed to follow')
1334 }
1335
1336 showToast(`Now following ${name}!`, 'success')
1337
1338 // Close modal and clear hash
1339 const modal = document.querySelector('.modal-overlay')
1340 if (modal) modal.remove()
1341 window.location.hash = ''
1342
1343 // Reload timeline
1344 setTimeout(loadTimeline, 1000)
1345 } catch (err) {
1346 showToast('Follow failed: ' + err.message, 'error')
1347 }
1348}
1349
1350// Share post - copy permalink to clipboard
1351window.sharePost = function(author, sequence) {
1352 const permalink = `${window.location.origin}${window.location.pathname}#msg:${author}@${sequence}`
1353
1354 navigator.clipboard.writeText(permalink)
1355 .then(() => {
1356 showToast('Link copied to clipboard!', 'success')
1357 })
1358 .catch(err => {
1359 console.error('Failed to copy:', err)
1360 showToast('Failed to copy link', 'error')
1361 })
1362}
1363
1364// Show single message in context
1365async function showMessageContext(author, sequence) {
1366 // Find the post in timeline
1367 const post = timeline.find(p => p.author === author && p.sequence === parseInt(sequence))
1368
1369 if (!post) {
1370 showToast('Message not found in timeline', 'error')
1371 return
1372 }
1373
1374 const modal = document.createElement('div')
1375 modal.className = 'modal-overlay'
1376 modal.onclick = (e) => {
1377 if (e.target === modal) {
1378 modal.remove()
1379 window.location.hash = ''
1380 }
1381 }
1382
1383 // Render the post (await since it's async)
1384 const postHtml = await renderPost(post)
1385
1386 // Find all replies to this post
1387 const replies = timeline.filter(p =>
1388 p.content.type === 'reply' &&
1389 p.content.target?.author === author &&
1390 p.content.target?.sequence === sequence
1391 )
1392
1393 // Sort replies by timestamp
1394 replies.sort((a, b) => a.timestamp - b.timestamp)
1395
1396 // Render all replies
1397 let repliesHtml = ''
1398 if (replies.length > 0) {
1399 const repliesRendered = await Promise.all(replies.map(r => renderPost(r)))
1400 repliesHtml = `
1401 <div style="margin-top: 1.5em;">
1402 <h3 style="font-size: 1.1em; color: #666; margin-bottom: 1em;">
1403 ${replies.length} ${replies.length === 1 ? 'Reply' : 'Replies'}
1404 </h3>
1405 <div style="display: flex; flex-direction: column; gap: 1em;">
1406 ${repliesRendered.join('')}
1407 </div>
1408 </div>
1409 `
1410 } else {
1411 repliesHtml = `
1412 <div style="margin-top: 1.5em; padding: 1em; background: #f9fafb; border-radius: 6px; text-align: center;">
1413 <p style="color: #666; font-size: 0.9em;">No replies yet. Be the first to reply!</p>
1414 </div>
1415 `
1416 }
1417
1418 modal.innerHTML = `
1419 <div class="modal-content" onclick="event.stopPropagation()">
1420 <div class="modal-header">
1421 <h2 class="modal-title">Message Context</h2>
1422 <button class="close-btn" onclick="this.closest('.modal-overlay').remove(); window.location.hash=''">×</button>
1423 </div>
1424
1425 <div style="margin-top: 1em;">
1426 ${postHtml}
1427 </div>
1428
1429 ${repliesHtml}
1430 </div>
1431 `
1432
1433 document.body.appendChild(modal)
1434}
1435
1436// Show search modal
1437function showSearchModal() {
1438 const modal = document.createElement('div')
1439 modal.className = 'modal-overlay'
1440 modal.onclick = (e) => {
1441 if (e.target === modal) modal.remove()
1442 }
1443
1444 modal.innerHTML = `
1445 <div class="modal-content">
1446 <div class="modal-header">
1447 <h2 class="modal-title">Search</h2>
1448 <button class="close-btn" onclick="this.closest('.modal-overlay').remove()">×</button>
1449 </div>
1450 <div>
1451 <label>Search for hashtags or content</label>
1452 <input type="text" id="search-input" placeholder="Enter hashtag (e.g., #wirecore) or search term" autofocus>
1453
1454 <div style="margin-top: 1.5em;">
1455 <button onclick="performSearch()" style="width: 100%; padding: 0.75em;">
1456 Search Timeline
1457 </button>
1458 </div>
1459
1460 <div id="search-results" style="margin-top: 1.5em; max-height: 400px; overflow-y: auto;">
1461 </div>
1462 </div>
1463 </div>
1464 `
1465
1466 document.body.appendChild(modal)
1467
1468 // Handle Enter key
1469 const searchInput = document.getElementById('search-input')
1470 searchInput.addEventListener('keypress', (e) => {
1471 if (e.key === 'Enter') {
1472 performSearch()
1473 }
1474 })
1475}
1476
1477// Perform search
1478window.performSearch = async function() {
1479 const searchInput = document.getElementById('search-input')
1480 const query = searchInput.value.trim()
1481
1482 if (!query) {
1483 showToast('Please enter a search term', 'error')
1484 return
1485 }
1486
1487 // If it's a hashtag, redirect to hashtag view
1488 if (query.startsWith('#')) {
1489 document.querySelector('.modal-overlay')?.remove()
1490 window.location.hash = '?' + query
1491 return
1492 }
1493
1494 // Otherwise, search timeline for content
1495 const resultsDiv = document.getElementById('search-results')
1496 resultsDiv.innerHTML = '<div class="loading">Searching</div>'
1497
1498 const filteredPosts = timeline.filter(post => {
1499 if (post.content.type !== 'post' && post.content.type !== 'reply') {
1500 return false
1501 }
1502 const text = post.content.text || ''
1503 const authorName = post.name || ''
1504 // Case-insensitive search
1505 const searchLower = query.toLowerCase()
1506 return text.toLowerCase().includes(searchLower) || authorName.toLowerCase().includes(searchLower)
1507 })
1508
1509 if (filteredPosts.length > 0) {
1510 const postsHtml = (await Promise.all(filteredPosts.slice(0, 20).map(post => renderPost(post)))).join('')
1511 resultsDiv.innerHTML = `
1512 <h3 style="margin-bottom: 1em; color: #666;">Found ${filteredPosts.length} result${filteredPosts.length === 1 ? '' : 's'}</h3>
1513 ${postsHtml}
1514 `
1515 } else {
1516 resultsDiv.innerHTML = `
1517 <div class="empty-state">
1518 <h3>No results found</h3>
1519 <p>Try a different search term</p>
1520 </div>
1521 `
1522 }
1523}
1524
1525// Show hashtag timeline
1526async function showHashtagTimeline(hashtag) {
1527 // Filter timeline for posts containing this hashtag
1528 const filteredPosts = timeline.filter(post => {
1529 if (post.content.type !== 'post' && post.content.type !== 'reply') {
1530 return false
1531 }
1532 const text = post.content.text || ''
1533 // Match hashtag (with word boundary)
1534 const hashtagRegex = new RegExp(`(^|\\s)${hashtag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$|[.,!?;:])`, 'i')
1535 return hashtagRegex.test(text)
1536 })
1537
1538 const modal = document.createElement('div')
1539 modal.className = 'modal-overlay'
1540 modal.onclick = (e) => {
1541 if (e.target === modal) {
1542 modal.remove()
1543 window.location.hash = ''
1544 }
1545 }
1546
1547 let postsHtml = ''
1548 if (filteredPosts.length > 0) {
1549 postsHtml = (await Promise.all(filteredPosts.map(post => renderPost(post)))).join('')
1550 } else {
1551 postsHtml = `
1552 <div class="empty-state">
1553 <h3>No posts found</h3>
1554 <p>No posts contain ${hashtag}</p>
1555 </div>
1556 `
1557 }
1558
1559 modal.innerHTML = `
1560 <div class="modal-content" onclick="event.stopPropagation()" style="max-width: 800px;">
1561 <div class="modal-header">
1562 <h2 class="modal-title">${hashtag} (${filteredPosts.length} ${filteredPosts.length === 1 ? 'post' : 'posts'})</h2>
1563 <button class="close-btn" onclick="this.closest('.modal-overlay').remove(); window.location.hash=''">×</button>
1564 </div>
1565
1566 <div style="max-height: 70vh; overflow-y: auto; margin-top: 1em;">
1567 ${postsHtml}
1568 </div>
1569 </div>
1570 `
1571
1572 document.body.appendChild(modal)
1573}
1574
1575// Listen for hash changes
1576window.addEventListener('hashchange', handleHashChange)
1577
1578// Handle initial hash on page load
1579if (window.location.hash) {
1580 // Delay to ensure app is initialized
1581 setTimeout(handleHashChange, 500)
1582}
1583
1584// Start the app
1585init()
pear-app/public/diagnostic.html +181 -0
……@@ -0,0 +1,181 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Wirecore Diagnostics</title>
7 <style>
8 body {
9 font-family: monospace;
10 padding: 20px;
11 max-width: 900px;
12 margin: 0 auto;
13 background: #1a1a1a;
14 color: #eee;
15 }
16 h1 { color: #10b981; }
17 h2 { color: #3b82f6; margin-top: 30px; }
18 pre {
19 background: #2a2a2a;
20 padding: 15px;
21 border-radius: 5px;
22 overflow-x: auto;
23 border-left: 3px solid #10b981;
24 }
25 button {
26 background: #10b981;
27 color: white;
28 border: none;
29 padding: 10px 20px;
30 border-radius: 5px;
31 cursor: pointer;
32 margin: 10px 5px;
33 }
34 button:hover {
35 background: #059669;
36 }
37 .error {
38 color: #ef4444;
39 }
40 .warning {
41 color: #f59e0b;
42 }
43 .success {
44 color: #10b981;
45 }
46 </style>
47</head>
48<body>
49 <h1>🔍 Wirecore Browser Diagnostics</h1>
50
51 <button onclick="checkIdentity()">Check Identity</button>
52 <button onclick="checkStorage()">Check Storage</button>
53 <button onclick="checkP2P()">Check P2P</button>
54 <button onclick="clearAll()">Clear All Data</button>
55
56 <div id="output"></div>
57
58 <script type="module">
59 import { apds } from './apds-compat.js'
60
61 await apds.start()
62
63 window.checkIdentity = async function() {
64 const output = document.getElementById('output')
65 output.innerHTML = '<h2>Identity Check</h2>'
66
67 try {
68 const keypair = await apds.keypair()
69 const pubkey = await apds.pubkey()
70 const name = await apds.get('name')
71
72 output.innerHTML += `
73 <pre>
74<span class="success">✓ Keypair exists</span>
75Length: ${keypair?.length || 'NULL'}
76Public Key: ${pubkey || 'NULL'}
77Name: ${name || 'NULL'}
78
79First 8 chars: ${pubkey?.substring(0, 8) || 'N/A'}...
80 </pre>
81 `
82 } catch (err) {
83 output.innerHTML += `<pre class="error">Error: ${err.message}</pre>`
84 }
85 }
86
87 window.checkStorage = async function() {
88 const output = document.getElementById('output')
89 output.innerHTML = '<h2>IndexedDB Storage</h2>'
90
91 try {
92 const db = await indexedDB.open('wirecore', 1)
93 const transaction = db.transaction(['identity'], 'readonly')
94 const store = transaction.objectStore('identity')
95
96 const getAllRequest = store.getAllKeys()
97
98 getAllRequest.onsuccess = async () => {
99 const keys = getAllRequest.result
100 output.innerHTML += `<pre>Stored keys: ${keys.length}\n\n`
101
102 for (const key of keys) {
103 const getRequest = store.get(key)
104 getRequest.onsuccess = () => {
105 const value = getRequest.result
106 const preview = typeof value === 'string'
107 ? (value.length > 100 ? value.substring(0, 100) + '...' : value)
108 : JSON.stringify(value)
109
110 output.innerHTML += `\n<span class="success">${key}</span>: ${preview}\n`
111 }
112 }
113
114 output.innerHTML += '</pre>'
115 }
116 } catch (err) {
117 output.innerHTML += `<pre class="error">Error: ${err.message}</pre>`
118 }
119 }
120
121 window.checkP2P = async function() {
122 const output = document.getElementById('output')
123 output.innerHTML = '<h2>P2P / Trystero Status</h2>'
124
125 const transport = window.trysteroTransport
126
127 if (!transport) {
128 output.innerHTML += `<pre class="error">✗ trysteroTransport is NULL
129This means Trystero hasn't been initialized.
130Check browser console for errors during init.</pre>`
131 return
132 }
133
134 const peerCount = transport.getPeerCount()
135 const stats = transport.getStats()
136 const hasRoom = !!transport.room
137
138 output.innerHTML += `
139 <pre>
140<span class="${hasRoom ? 'success' : 'error'}">${hasRoom ? '✓' : '✗'} Room: ${hasRoom ? 'ACTIVE' : 'NOT JOINED'}</span>
141<span class="${peerCount > 0 ? 'success' : 'warning'}">${peerCount > 0 ? '✓' : '⚠'} Peers: ${peerCount}</span>
142
143Stats:
144 Sent: ${stats.sent}
145 Received: ${stats.received}
146 Pending Requests: ${stats.pendingRequests}
147
148Peer IDs:
149${Array.from(transport.peers).map(p => ` - ${p}`).join('\n') || ' (none)'}
150 </pre>
151 `
152 }
153
154 window.clearAll = async function() {
155 if (!confirm('This will DELETE all your identity data and profile pictures. Continue?')) {
156 return
157 }
158
159 const output = document.getElementById('output')
160 output.innerHTML = '<h2>Clearing Data...</h2>'
161
162 try {
163 // Clear IndexedDB
164 await indexedDB.deleteDatabase('wirecore')
165
166 output.innerHTML += `<pre class="success">✓ Cleared IndexedDB
167
168Refresh the page to generate a new identity.</pre>`
169 } catch (err) {
170 output.innerHTML += `<pre class="error">Error: ${err.message}</pre>`
171 }
172 }
173
174 // Auto-run checks on load
175 setTimeout(() => {
176 checkIdentity()
177 setTimeout(() => checkP2P(), 1000)
178 }, 500)
179 </script>
180</body>
181</html>
pear-app/public/favicon.svg +4 -0
……@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
2 <rect fill="#6366f1" width="100" height="100" rx="20"/>
3 <text x="50" y="75" font-size="60" text-anchor="middle">🕊️</text>
4</svg>
pear-app/public/markdown.js +200 -0
……@@ -0,0 +1,200 @@
1/**
2 * Markdown rendering for Wirecore
3 * Based on wiredove's markdown.js
4 */
5
6import { marked } from 'https://esm.sh/marked@12.0.0'
7
8const renderer = new marked.Renderer()
9
10// Custom paragraph renderer to handle hashtags
11renderer.paragraph = function (text) {
12 const words = text.split(' ')
13
14 for (let i = 0; i < words.length; i++) {
15 let word = words[i]
16
17 // Handle hashtags
18 if (word.startsWith('#')) {
19 let punctuation = ''
20
21 // Extract trailing punctuation
22 if (['.', ',', '?', ':', '!'].some(char => word.endsWith(char))) {
23 punctuation = word[word.length - 1]
24 word = word.substring(0, word.length - 1)
25 }
26
27 // Create hashtag link
28 const hashtag = `<a href="#?${word}" class="hashtag">${word}</a>`
29 words[i] = hashtag + punctuation
30 }
31 }
32
33 const paragraph = words.join(' ')
34 return '<p>' + paragraph + '</p>'
35}
36
37// Custom link renderer for ANProto public keys
38renderer.link = function (href, title, text) {
39 // If it's a 44-char base64 string (ANProto public key), make it a profile link
40 if (href.length === 44 && !href.startsWith('http')) {
41 href = '#' + href
42 }
43
44 // Add target="_blank" for external links
45 if (href.startsWith('http://') || href.startsWith('https://')) {
46 return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`
47 }
48
49 return `<a href="${href}">${text}</a>`
50}
51
52// Custom image renderer for inline images
53renderer.image = function (src, title, text) {
54 // Handle content hashes (44-char base64 from uploaded images)
55 if (src && src.length === 44) {
56 const altText = text ? text.replace(/"/g, '&quot;') : 'Post image'
57 const imgId = `inline-img-${src}`
58
59 // Schedule image loading from IndexedDB after render
60 setTimeout(async () => {
61 try {
62 // Try to load from apds storage
63 const { apds } = await import('./apds-compat.js')
64 let imageData = await apds.get(`post-image:${src}`)
65
66 const imgElement = document.getElementById(imgId)
67
68 if (imageData) {
69 console.log(`✓ Found image ${src.substring(0, 8)}... locally`)
70 } else {
71 console.log(`⚠ Image ${src.substring(0, 8)}... not found locally`)
72
73 // If not found locally, try requesting from P2P peers
74 if (window.trysteroTransport && window.trysteroTransport.hasActivePeers()) {
75 const peerCount = window.trysteroTransport.getPeerCount()
76 console.log(`Requesting image ${src.substring(0, 8)}... from ${peerCount} P2P peers`)
77 try {
78 imageData = await window.trysteroTransport.requestContent(src, { timeout: 5000 })
79 if (imageData) {
80 // Store for future use
81 await apds.put(`post-image:${src}`, imageData)
82 console.log(`✓ Received image ${src.substring(0, 8)}... from peers`)
83 } else {
84 console.log(`✗ No peers had image ${src.substring(0, 8)}...`)
85 }
86 } catch (err) {
87 console.log(`✗ P2P request timeout for image ${src.substring(0, 8)}...`)
88 }
89 } else {
90 console.log(`✗ No P2P peers available for image ${src.substring(0, 8)}...`)
91 }
92 }
93
94 if (imgElement && imageData) {
95 imgElement.src = imageData
96 imgElement.style.display = 'block'
97 } else if (imgElement) {
98 // Image not found - show placeholder
99 imgElement.alt = 'Image not available (offline)'
100 imgElement.style.display = 'none'
101 console.log(`✗ Image ${src.substring(0, 8)}... could not be loaded`)
102 }
103 } catch (err) {
104 console.error('Failed to load image:', src, err)
105 }
106 }, 10)
107
108 return `<img class="post-image" id="${imgId}" alt="${altText}" loading="lazy" onclick="openImageLightbox(this)" style="display:none; max-width: 100%;" />`
109 }
110
111 // Regular images - make them clickable for lightbox
112 const altText = text || 'Image'
113 const titleAttr = title ? `title="${title.replace(/"/g, '&quot;')}"` : ''
114 return `<img class="post-image" src="${src}" alt="${altText}" ${titleAttr} loading="lazy" onclick="openImageLightbox(this)" />`
115}
116
117// Configure marked
118marked.setOptions({
119 renderer: renderer,
120 breaks: true, // Convert \n to <br>
121 gfm: true // GitHub Flavored Markdown
122})
123
124/**
125 * Parse markdown text to HTML
126 * @param {string} text - Raw text content
127 * @returns {Promise<string>} HTML string
128 */
129export async function markdown(text) {
130 if (!text) return ''
131
132 try {
133 const html = await marked.parse(text)
134 return html
135 } catch (err) {
136 console.error('Markdown parsing error:', err)
137 // Fallback: escape HTML and preserve line breaks
138 return text
139 .replace(/&/g, '&amp;')
140 .replace(/</g, '&lt;')
141 .replace(/>/g, '&gt;')
142 .replace(/\n/g, '<br>')
143 }
144}
145
146/**
147 * Auto-link URLs and hashtags in plain text
148 * For content that isn't markdown
149 * Also embeds images from URLs
150 * @param {string} text - Plain text content
151 * @returns {string} HTML with clickable links and embedded images
152 */
153export function autoLink(text) {
154 if (!text) return ''
155
156 // Image URL regex pattern (jpg, jpeg, png, gif, webp)
157 const imagePattern = /(https?:\/\/[^\s<]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s<]*)?)/gi
158
159 // URL regex pattern (for non-images)
160 const urlPattern = /(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g
161
162 // Hashtag pattern
163 const hashtagPattern = /(^|\s)(#[a-zA-Z0-9_-]+)/g
164
165 let html = text
166 .replace(/&/g, '&amp;')
167 .replace(/</g, '&lt;')
168 .replace(/>/g, '&gt;')
169
170 // First, embed images
171 const images = []
172 html = html.replace(imagePattern, (match) => {
173 const placeholder = `__IMAGE_${images.length}__`
174 images.push(match)
175 return placeholder
176 })
177
178 // Link remaining URLs
179 html = html.replace(urlPattern, (match) => {
180 // Skip if it's an image (already handled)
181 if (match.match(/\.(?:jpg|jpeg|png|gif|webp)(?:\?|$)/i)) {
182 return match
183 }
184 return `<a href="${match}" target="_blank" rel="noopener noreferrer">${match}</a>`
185 })
186
187 // Link hashtags
188 html = html.replace(hashtagPattern, '$1<a href="#?$2" class="hashtag">$2</a>')
189
190 // Replace image placeholders with actual img tags
191 images.forEach((imgUrl, index) => {
192 const imgTag = `<br><img class="post-image" src="${imgUrl}" alt="Image" loading="lazy" onclick="openImageLightbox(this)" /><br>`
193 html = html.replace(`__IMAGE_${index}__`, imgTag)
194 })
195
196 // Preserve line breaks
197 html = html.replace(/\n/g, '<br>')
198
199 return html
200}
pear-app/public/test-p2p.html +148 -0
……@@ -0,0 +1,148 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Wirecore P2P Test</title>
7 <style>
8 body {
9 font-family: monospace;
10 padding: 20px;
11 background: #1a1a1a;
12 color: #00ff00;
13 }
14 h1 { color: #00ff00; }
15 .status {
16 padding: 20px;
17 margin: 10px 0;
18 border: 2px solid #00ff00;
19 border-radius: 5px;
20 }
21 .success { border-color: #00ff00; color: #00ff00; }
22 .error { border-color: #ff0000; color: #ff0000; }
23 .waiting { border-color: #ffff00; color: #ffff00; }
24 #log {
25 background: #000;
26 padding: 10px;
27 min-height: 300px;
28 max-height: 500px;
29 overflow-y: auto;
30 border: 1px solid #333;
31 margin-top: 20px;
32 }
33 .log-entry {
34 margin: 5px 0;
35 font-size: 14px;
36 }
37 </style>
38</head>
39<body>
40 <h1>🧪 Wirecore P2P Connection Test</h1>
41
42 <div class="status waiting" id="status">
43 <h2>Status: Initializing...</h2>
44 <p id="peer-count">Peers: 0</p>
45 <p id="test-result">Waiting for connection...</p>
46 </div>
47
48 <div id="log"></div>
49
50 <script type="module">
51 import { joinRoom } from './trystero-torrent.min.js'
52
53 const log = document.getElementById('log')
54 const status = document.getElementById('status')
55 const peerCount = document.getElementById('peer-count')
56 const testResult = document.getElementById('test-result')
57
58 function addLog(message, type = 'info') {
59 const entry = document.createElement('div')
60 entry.className = 'log-entry'
61 const timestamp = new Date().toLocaleTimeString()
62 entry.textContent = `[${timestamp}] ${message}`
63 if (type === 'success') entry.style.color = '#00ff00'
64 if (type === 'error') entry.style.color = '#ff0000'
65 if (type === 'warn') entry.style.color = '#ffff00'
66 log.appendChild(entry)
67 log.scrollTop = log.scrollHeight
68 }
69
70 addLog('🚀 Starting P2P test...', 'info')
71
72 const room = joinRoom(
73 {
74 appId: 'wirecore-v1',
75 password: 'wirecore-p2p-2026',
76 relayUrls: [
77 'ws://localhost:8000',
78 'wss://tracker.openwebtorrent.com',
79 'wss://tracker.files.fm:7073/announce'
80 ],
81 rtcConfig: {
82 iceServers: [
83 { urls: 'stun:stun.l.google.com:19302' },
84 { urls: 'stun:stun.cloudflare.com:3478' },
85 {
86 urls: 'turn:142.93.23.112:3478',
87 username: 'wirecore',
88 credential: 'wirecore8261a6a559ce533c'
89 },
90 {
91 urls: 'turn:142.93.23.112:5349',
92 username: 'wirecore',
93 credential: 'wirecore8261a6a559ce533c'
94 }
95 ]
96 }
97 },
98 'wirecorev1-test'
99 )
100
101 addLog('✅ Joined room: wirecorev1-test', 'success')
102 addLog('⏳ Waiting for peers...', 'warn')
103
104 let connectedPeers = new Set()
105
106 room.onPeerJoin(peerId => {
107 connectedPeers.add(peerId)
108 const peerShort = peerId.substring(0, 12) + '...'
109 addLog(`✅ Peer connected: ${peerShort}`, 'success')
110
111 peerCount.textContent = `Peers: ${connectedPeers.size}`
112
113 if (connectedPeers.size > 0) {
114 status.className = 'status success'
115 testResult.textContent = '✅ SUCCESS! P2P connection established!'
116 document.querySelector('h2').textContent = 'Status: CONNECTED ✅'
117 }
118 })
119
120 room.onPeerLeave(peerId => {
121 connectedPeers.delete(peerId)
122 const peerShort = peerId.substring(0, 12) + '...'
123 addLog(`❌ Peer disconnected: ${peerShort}`, 'error')
124
125 peerCount.textContent = `Peers: ${connectedPeers.size}`
126
127 if (connectedPeers.size === 0) {
128 status.className = 'status waiting'
129 testResult.textContent = 'Waiting for connection...'
130 document.querySelector('h2').textContent = 'Status: Waiting for peers...'
131 }
132 })
133
134 // Timeout check
135 setTimeout(() => {
136 if (connectedPeers.size === 0) {
137 status.className = 'status error'
138 testResult.textContent = '❌ TIMEOUT: No peers connected after 60 seconds'
139 document.querySelector('h2').textContent = 'Status: FAILED'
140 addLog('❌ Test failed: No peers connected', 'error')
141 addLog('Check TURN server configuration and firewall', 'error')
142 }
143 }, 60000)
144
145 addLog('💡 Open this page in another browser window to test P2P!', 'warn')
146 </script>
147</body>
148</html>
pear-app/public/trystero-content-transport.js +297 -0
……@@ -0,0 +1,297 @@
1/**
2 * Trystero Content Transport (Browser-side)
3 *
4 * WebRTC-based content-by-hash synchronization for browsers.
5 * Compatible with wiredove's sendHash/sendBlob pattern.
6 *
7 * Uses Trystero for signaling and WebRTC connections.
8 * Works with browser's IndexedDB storage via apds.
9 */
10
11export class TrysteroContentTransport {
12 constructor(apds, options = {}) {
13 this.apds = apds
14 this.options = {
15 appId: options.appId || 'wirecore-v1',
16 password: options.password || 'wirecore-content-sync-2026',
17 ...options
18 }
19
20 this.room = null
21 this.sendHash = null
22 this.sendBlob = null
23 this.peers = new Set()
24
25 // Pending requests: requestId -> { hash, resolve, reject, timeout }
26 this.pendingRequests = new Map()
27
28 // Stats
29 this.stats = {
30 sent: 0,
31 received: 0,
32 peers: 0
33 }
34
35 console.log('TrysteroContentTransport: initialized')
36 }
37
38 /**
39 * Join a Trystero room for P2P communication
40 * @param {string} roomName - Room identifier (usually public key)
41 * @param {Function} joinRoomFn - Trystero's joinRoom function
42 */
43 async joinRoom(roomName, joinRoomFn) {
44 if (this.room) {
45 console.log('TrysteroContentTransport: already in room')
46 return
47 }
48
49 console.log(`TrysteroContentTransport: joining room ${roomName}`)
50
51 // Create Trystero room
52 const roomConfig = {
53 appId: this.options.appId,
54 password: this.options.password
55 }
56
57 // Add custom relay URLs if provided
58 if (this.options.relayUrls) {
59 roomConfig.relayUrls = this.options.relayUrls
60 }
61
62 // Add custom RTC config (TURN/STUN servers) if provided
63 if (this.options.rtcConfig) {
64 roomConfig.rtcConfig = this.options.rtcConfig
65 }
66
67 const room = joinRoomFn(roomConfig, roomName)
68
69 // Create hash and blob actions (matching wiredove's pattern)
70 const [sendHash, onHash] = room.makeAction('hash')
71 const [sendBlob, onBlob] = room.makeAction('blob')
72
73 this.room = room
74 this.sendHash = sendHash
75 this.sendBlob = sendBlob
76
77 // Handle incoming hash requests
78 onHash(async (data, peerId) => {
79 console.log(`TrysteroContentTransport: received hash request from ${peerId}:`, data)
80 await this.handleHashRequest(data, peerId)
81 })
82
83 // Handle incoming blob responses
84 onBlob(async (data, peerId) => {
85 console.log(`TrysteroContentTransport: received blob from ${peerId}`)
86 await this.handleBlobResponse(data, peerId)
87 })
88
89 // Track peers
90 room.onPeerJoin((peerId) => {
91 console.log(`TrysteroContentTransport: peer joined ${peerId}`)
92 this.peers.add(peerId)
93 this.stats.peers = this.peers.size
94
95 // Send our latest content when peer joins
96 this.announceLatestContent(peerId)
97 })
98
99 room.onPeerLeave((peerId) => {
100 console.log(`TrysteroContentTransport: peer left ${peerId}`)
101 this.peers.delete(peerId)
102 this.stats.peers = this.peers.size
103 })
104
105 console.log('TrysteroContentTransport: room joined successfully')
106 }
107
108 /**
109 * Announce latest content to a specific peer
110 */
111 async announceLatestContent(peerId) {
112 try {
113 const pubkey = await this.apds.pubkey()
114 if (!pubkey) return
115
116 // Get latest message from own feed
117 // This would need integration with Wirecore's feed system
118 // For now, just log
119 console.log(`TrysteroContentTransport: would announce latest to ${peerId}`)
120 } catch (err) {
121 console.error('TrysteroContentTransport: failed to announce latest:', err)
122 }
123 }
124
125 /**
126 * Handle incoming hash request from peer
127 * Peer is asking if we have content for this hash
128 */
129 async handleHashRequest(data, peerId) {
130 try {
131 const { hash, requestId } = typeof data === 'string' ? { hash: data, requestId: null } : data
132
133 if (!hash) {
134 console.error('TrysteroContentTransport: invalid hash request')
135 return
136 }
137
138 // Try to get content from local storage
139 let content = null
140
141 // Check if this is a profile-image request (starts with 'profile-image:')
142 if (hash.startsWith('profile-image:')) {
143 // For own profile image
144 if (hash === `profile-image:${await this.apds.pubkey()}`) {
145 content = await this.apds.get('profile-image')
146 } else {
147 // For other users (we might have cached it)
148 content = await this.apds.get(hash)
149 }
150 } else {
151 // Check post images
152 const postImage = await this.apds.get(`post-image:${hash}`)
153 if (postImage) {
154 content = postImage
155 } else {
156 // Check regular content
157 content = await this.apds.get(hash)
158 }
159 }
160
161 if (content) {
162 console.log(`TrysteroContentTransport: found content for ${hash}, sending to ${peerId}`)
163
164 // Send blob back to requesting peer
165 const response = requestId ? { content, hash, requestId } : content
166 this.sendBlob(response, peerId)
167 this.stats.sent++
168 } else {
169 console.log(`TrysteroContentTransport: content not found for ${hash}`)
170 }
171 } catch (err) {
172 console.error('TrysteroContentTransport: error handling hash request:', err)
173 }
174 }
175
176 /**
177 * Handle incoming blob response from peer
178 * Peer is sending us content we requested
179 */
180 async handleBlobResponse(data, peerId) {
181 try {
182 const { content, hash, requestId } = typeof data === 'string' ? { content: data, hash: null, requestId: null } : data
183
184 if (!content) {
185 console.error('TrysteroContentTransport: invalid blob response')
186 return
187 }
188
189 this.stats.received++
190
191 // If this is a response to a request, resolve the promise
192 if (requestId && this.pendingRequests.has(requestId)) {
193 const pending = this.pendingRequests.get(requestId)
194 clearTimeout(pending.timeout)
195 this.pendingRequests.delete(requestId)
196 pending.resolve(content)
197 return
198 }
199
200 // Otherwise, this is unsolicited content (e.g., new post)
201 // Store it and process it
202 if (hash) {
203 await this.apds.put(`post-image:${hash}`, content)
204 console.log(`TrysteroContentTransport: stored content for ${hash}`)
205 }
206
207 // Emit event for processing
208 this.emit?.('blob-received', { content, hash, peerId })
209 } catch (err) {
210 console.error('TrysteroContentTransport: error handling blob response:', err)
211 }
212 }
213
214 /**
215 * Request content from peers
216 * @param {string} hash - Content hash to request
217 * @param {Object} options - { timeout }
218 * @returns {Promise<string>} Content or null
219 */
220 async requestContent(hash, options = {}) {
221 const timeout = options.timeout || 10000
222 const requestId = this.generateRequestId()
223
224 console.log(`TrysteroContentTransport: requesting content for ${hash}`)
225
226 // Create promise for this request
227 const promise = new Promise((resolve, reject) => {
228 const timeoutId = setTimeout(() => {
229 this.pendingRequests.delete(requestId)
230 reject(new Error(`Timeout requesting ${hash}`))
231 }, timeout)
232
233 this.pendingRequests.set(requestId, {
234 hash,
235 resolve,
236 reject,
237 timeout: timeoutId
238 })
239 })
240
241 // Send hash request to all peers
242 if (this.sendHash) {
243 this.sendHash({ hash, requestId })
244 this.stats.sent++
245 }
246
247 return promise
248 }
249
250 /**
251 * Generate unique request ID
252 */
253 generateRequestId() {
254 return `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
255 }
256
257 /**
258 * Check if we have active peers
259 */
260 hasActivePeers() {
261 return this.peers.size > 0
262 }
263
264 /**
265 * Get peer count
266 */
267 getPeerCount() {
268 return this.peers.size
269 }
270
271 /**
272 * Leave room and cleanup
273 */
274 leaveRoom() {
275 if (this.room) {
276 console.log('TrysteroContentTransport: leaving room')
277 this.room.leave()
278 this.room = null
279 this.sendHash = null
280 this.sendBlob = null
281 this.peers.clear()
282 this.pendingRequests.clear()
283 }
284 }
285
286 /**
287 * Get stats
288 */
289 getStats() {
290 return {
291 ...this.stats,
292 pendingRequests: this.pendingRequests.size
293 }
294 }
295}
296
297export default TrysteroContentTransport
pear-app/public/trystero-torrent.min.js +1 -0
……@@ -0,0 +1 @@
1const{floor:e,random:r}=Math,t="Trystero",n=(e,r)=>Array(e).fill().map(r),a="0123456789AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz",o=t=>n(t,(()=>a[e(62*r())])).join(""),s=o(20),i=Promise.all.bind(Promise),c="undefined"!=typeof window,{entries:l,fromEntries:d,keys:u}=Object,f=()=>{},y=e=>Error(`${t}: ${e}`),p=new TextEncoder,m=new TextDecoder,w=e=>p.encode(e),g=e=>m.decode(e),h=(...e)=>e.join("@"),b=JSON.stringify,k=JSON.parse,v={};let P=null,T=null;const A=()=>{P||(P=new Promise((e=>{T=e})).finally((()=>{T=null,P=null})))},S=()=>T?.(),L="AES-GCM",D={},I=async e=>D[e]||=Array.from(await(async(e,r)=>new Uint8Array(await crypto.subtle.digest(e,w(r))))("SHA-1",e)).map((e=>e.toString(36))).join(""),$=async(e,r)=>{const t=crypto.getRandomValues(new Uint8Array(16));return t.join(",")+"$"+(n=await crypto.subtle.encrypt({name:L,iv:t},await e,w(r)),btoa(String.fromCharCode.apply(null,new Uint8Array(n))));var n},E=async(e,r)=>{const[t,n]=r.split("$");return g(await crypto.subtle.decrypt({name:L,iv:new Uint8Array(t.split(","))},await e,(e=>{const r=atob(e);return new Uint8Array(r.length).map(((e,t)=>r.charCodeAt(t))).buffer})(n)))},C="icegatheringstatechange",U="offer";var _=(e,{rtcConfig:r,rtcPolyfill:t,turnConfig:n})=>{const a=new(t||RTCPeerConnection)({iceServers:O.concat(n||[]),...r}),o={};let s=!1,c=!1,l=null;const d=e=>{e.binaryType="arraybuffer",e.bufferedAmountLowThreshold=65535,e.onmessage=e=>o.data?.(e.data),e.onopen=()=>o.connect?.(),e.onclose=()=>o.close?.(),e.onerror=e=>o.error?.(e)},u=e=>Promise.race([new Promise((r=>{const t=()=>{"complete"===e.iceGatheringState&&(e.removeEventListener(C,t),r())};e.addEventListener(C,t),t()})),new Promise((e=>setTimeout(e,5e3)))]).then((()=>({type:e.localDescription.type,sdp:e.localDescription.sdp.replace(/a=ice-options:trickle\s\n/g,"")})));return e?(l=a.createDataChannel("data"),d(l)):a.ondatachannel=({channel:e})=>{l=e,d(e)},a.onnegotiationneeded=async()=>{try{s=!0,await a.setLocalDescription();const e=await u(a);o.signal?.(e)}catch(e){o.error?.(e)}finally{s=!1}},a.onconnectionstatechange=()=>{["disconnected","failed","closed"].includes(a.connectionState)&&o.close?.()},a.ontrack=e=>{o.track?.(e.track,e.streams[0]),o.stream?.(e.streams[0])},a.onremovestream=e=>o.stream?.(e.stream),e&&(a.canTrickleIceCandidates||a.onnegotiationneeded()),{created:Date.now(),connection:a,get channel(){return l},get isDead(){return"closed"===a.connectionState},async signal(r){if("open"!==l?.readyState||r.sdp?.includes("a=rtpmap"))try{if(r.type===U){if(s||"stable"!==a.signalingState&&!c){if(e)return;await i([a.setLocalDescription({type:"rollback"}),a.setRemoteDescription(r)])}else await a.setRemoteDescription(r);await a.setLocalDescription();const t=await u(a);return o.signal?.(t),t}if("answer"===r.type){c=!0;try{await a.setRemoteDescription(r)}finally{c=!1}}}catch(e){o.error?.(e)}},sendData(e){return l.send(e)},destroy(){l?.close(),a.close(),s=!1,c=!1},setHandlers(e){return Object.assign(o,e)},offerPromise:e?new Promise((e=>o.signal=r=>{r.type===U&&e(r)})):Promise.resolve(),addStream(e){return e.getTracks().forEach((r=>a.addTrack(r,e)))},removeStream(e){return a.getSenders().filter((r=>e.getTracks().includes(r.track))).forEach((e=>a.removeTrack(e)))},addTrack(e,r){return a.addTrack(e,r)},removeTrack(e){const r=a.getSenders().find((r=>r.track===e));r&&a.removeTrack(r)},replaceTrack(e,r){const t=a.getSenders().find((r=>r.track===e));if(t)return t.replaceTrack(r)}}};const O=[...n(3,((e,r)=>`stun:stun${r||""}.l.google.com:19302`)),"stun:stun.cloudflare.com:3478"].map((e=>({urls:e}))),j=Object.getPrototypeOf(Uint8Array),H=16369,R=255,J="bufferedamountlow",M=e=>"@_"+e;var x=(e,r,a)=>{const o={},s={},p={},m={},h={},v={},P={},T={onPeerJoin:f,onPeerLeave:f,onPeerStream:f,onPeerTrack:f},A=(e,r)=>(e?Array.isArray(e)?e:[e]:u(o)).flatMap((e=>{const n=o[e];return n?r(e,n):(console.warn(`${t}: no peer with id ${e} found`),[])})),S=e=>{o[e]&&(o[e].destroy(),delete o[e],delete m[e],delete h[e],T.onPeerLeave(e),r(e))},L=e=>{if(s[e])return p[e];if(!e)throw y("action type argument is required");const r=w(e);if(r.byteLength>12)throw y(`action type string "${e}" (${r.byteLength}b) exceeds byte limit (12). Hint: choose a shorter name.`);const t=new Uint8Array(12);t.set(r);let a=0;return s[e]={onComplete:f,onProgress:f,setOnComplete(r){return s[e]={...s[e],onComplete:r}},setOnProgress(r){return s[e]={...s[e],onProgress:r}},async send(e,r,s,c){if(s&&"object"!=typeof s)throw y("action meta argument must be an object");const l=typeof e;if("undefined"===l)throw y("action data cannot be undefined");const d="string"!==l,u=e instanceof Blob,f=u||e instanceof ArrayBuffer||e instanceof j;if(s&&!f)throw y("action meta argument can only be used with binary data");const p=f?new Uint8Array(u?await e.arrayBuffer():e):w(d?b(e):e),m=s?w(b(s)):null,g=Math.ceil(p.byteLength/H)+(s?1:0)||1,h=n(g,((e,r)=>{const n=r===g-1,o=s&&0===r,i=new Uint8Array(15+(o?m.byteLength:n?p.byteLength-H*(g-(s?2:1)):H));return i.set(t),i.set([a],12),i.set([n|o<<1|f<<2|d<<3],13),i.set([Math.round((r+1)/g*R)],14),i.set(s?o?m:p.subarray((r-1)*H,r*H):p.subarray(r*H,(r+1)*H),15),i}));return a=a+1&R,i(A(r,(async(e,r)=>{const{channel:t}=r;let n=0;for(;n<g;){const a=h[n];if(t.bufferedAmount>t.bufferedAmountLowThreshold&&await new Promise((e=>{const r=()=>{t.removeEventListener(J,r),e()};t.addEventListener(J,r)})),!o[e])break;r.sendData(a),n++,c?.(a[14]/R,e,s)}})))}},p[e]||=[s[e].send,s[e].setOnComplete,s[e].setOnProgress]},D=async()=>{await G(""),await new Promise((e=>setTimeout(e,99))),l(o).forEach((([e,r])=>{r.destroy(),delete o[e]})),a()},[I,$]=L(M("ping")),[E,C]=L(M("pong")),[U,_]=L(M("signal")),[O,x]=L(M("stream")),[q,B]=L(M("track")),[G,N]=L(M("leave"));return e(((e,r)=>{o[r]||(o[r]=e,e.setHandlers({data(e){return((e,r)=>{const n=new Uint8Array(r),a=g(n.subarray(0,12)).replaceAll("\0",""),[o]=n.subarray(12,13),[i]=n.subarray(13,14),[c]=n.subarray(14,15),l=n.subarray(15),d=!!(1&i),u=!!(2&i),f=!!(4&i),y=!!(8&i);if(!s[a])return void console.warn(`${t}: received message with unregistered type (${a})`);m[e]||={},m[e][a]||={};const p=m[e][a][o]||={chunks:[]};if(u?p.meta=k(g(l)):p.chunks.push(l),s[a].onProgress(c/R,e,p.meta),!d)return;const w=new Uint8Array(p.chunks.reduce(((e,r)=>e+r.byteLength),0));if(p.chunks.reduce(((e,r)=>(w.set(r,e),e+r.byteLength)),0),delete m[e][a][o],f)s[a].onComplete(w,e,p.meta);else{const r=g(w);s[a].onComplete(y?k(r):r,e)}})(r,e)},stream(e){T.onPeerStream(e,r,v[r]),delete v[r]},track(e,t){T.onPeerTrack(e,t,r,P[r]),delete P[r]},signal(e){return U(e,r)},close(){return S(r)},error(e){console.error(e),S(r)}}),T.onPeerJoin(r))})),$(((e,r)=>E("",r))),C(((e,r)=>{h[r]?.(),delete h[r]})),_(((e,r)=>o[r]?.signal(e))),x(((e,r)=>v[r]=e)),B(((e,r)=>P[r]=e)),N(((e,r)=>S(r))),c&&addEventListener("beforeunload",D),{makeAction:L,leave:D,async ping(e){if(!e)throw y("ping() must be called with target peer ID");const r=Date.now();return I("",e),await new Promise((r=>h[e]=r)),Date.now()-r},getPeers(){return d(l(o).map((([e,r])=>[e,r.connection])))},addStream(e,r,t){return A(r,(async(r,n)=>{t&&await O(t,r),n.addStream(e)}))},removeStream(e,r){return A(r,((r,t)=>t.removeStream(e)))},addTrack(e,r,t,n){return A(t,(async(t,a)=>{n&&await q(n,t),a.addTrack(e,r)}))},removeTrack(e,r){return A(r,((r,t)=>t.removeTrack(e)))},replaceTrack(e,r,t,n){return A(t,(async(t,a)=>{n&&await q(n,t),a.replaceTrack(e,r)}))},onPeerJoin(e){return T.onPeerJoin=e},onPeerLeave(e){return T.onPeerLeave=e},onPeerStream(e){return T.onPeerStream=e},onPeerTrack(e){return T.onPeerTrack=e}}};const q={},B={},G={},N={},z={},K={},V={},W={},F=async e=>{if(B[e])return B[e];const r=(await I(e)).slice(0,20);return B[e]=r,G[r]=e,r},Q=async(e,r,t)=>e.send(b({action:"announce",info_hash:await F(r),peer_id:s,...t})),X=(e,r,n)=>console.warn(`${t}: torrent tracker ${n?"failure":"warning"} from ${e} - ${r}`),Y=(({init:e,subscribe:r,announce:a})=>{const o={};let l,d,u,p,m=!1;return(g,v,P)=>{const{appId:T}=g;if(o[T]?.[v])return o[T][v];const D={},C={},U=h(t,T,v),O=I(U),j=I(h(U,s)),H=(async(e,r,t)=>crypto.subtle.importKey("raw",await crypto.subtle.digest({name:"SHA-256"},w(`${e}:${r}:${t}`)),{name:L},!1,["encrypt","decrypt"]))(g.password||"",T,v),R=e=>async r=>({type:r.type,sdp:await e(H,r.sdp)}),J=R(E),M=R($),q=()=>_(!0,g),B=(e,r,t)=>{C[r]?C[r]!==e&&e.destroy():(C[r]=e,Q(e,r),D[r]?.forEach(((e,r)=>{r!==t&&e.destroy()})),delete D[r])},G=(e,r)=>{C[r]===e&&delete C[r]},N=e=>(d.push(...n(e,q)),i(d.splice(0,e).map((e=>e.offerPromise.then(M).then((r=>({peer:e,offer:r}))))))),z=(e,r)=>P?.({error:`incorrect password (${g.password}) when decrypting ${r}`,appId:T,peerId:e,roomId:v}),K=e=>async(r,t,n)=>{const[a,o]=await i([O,j]);if(r!==a&&r!==o)return;const{peerId:c,offer:l,answer:d,peer:u}="string"==typeof t?k(t):t;if(c!==s&&!C[c])if(!c||l||d){if(l){const r=D[c]?.[e];if(r&&s>c)return;const t=_(!1,g);let a;t.setHandlers({connect(){return B(t,c,e)},close(){return G(t,c)}});try{a=await J(l)}catch{return void z(c,"offer")}if(t.isDead)return;const[o,d]=await i([I(h(U,c)),t.signal(a)]);n(o,b({peerId:s,answer:await M(d)}))}else if(d){let r;try{r=await J(d)}catch(e){return void z(c,"answer")}if(u)u.setHandlers({connect(){return B(u,c,e)},close(){return G(u,c)}}),u.signal(r);else{const t=D[c]?.[e];t&&!t.isDead&&t.signal(r)}}}else{if(D[c]?.[e])return;const[[{peer:r,offer:t}],a]=await i([N(1),I(h(U,c))]);D[c]||=[],D[c][e]=r,setTimeout((()=>((e,r)=>{if(C[e])return;const t=D[e]?.[r];t&&(delete D[e][r],t.destroy())})(c,e)),.9*V[e]),r.setHandlers({connect(){return B(r,c,e)},close(){return G(r,c)}}),n(a,b({peerId:s,offer:t}))}};if(!g)throw y("requires a config map as the first argument");if(!T&&!g.firebaseApp)throw y("config map is missing appId field");if(!v)throw y("roomId argument required");if(!m){const r=e(g);d=n(20,q),l=Array.isArray(r)?r:[r],m=!0,u=setInterval((()=>d=d.filter((e=>{const r=Date.now()-e.created<57333;return r||e.destroy(),r}))),59052.99),p=g.manualRelayReconnection?f:(()=>{if(c){const e=new AbortController;return addEventListener("online",S,{signal:e.signal}),addEventListener("offline",A,{signal:e.signal}),()=>e.abort()}return f})()}const V=l.map((()=>5333)),W=[],F=l.map((async(e,t)=>r(await e,await O,await j,K(t),N)));i([O,j]).then((([e,r])=>{const t=async(n,o)=>{const s=await a(n,e,r);"number"==typeof s&&(V[o]=s),W[o]=setTimeout((()=>t(n,o)),V[o])};F.forEach((async(e,r)=>{await e,t(await l[r],r)}))}));let Q=f;return o[T]||={},o[T][v]=x((e=>Q=e),(e=>delete C[e]),(()=>{delete o[T][v],W.forEach(clearTimeout),F.forEach((async e=>(await e)())),clearInterval(u),p(),m=!1}))}})({init(e){return((e,r,t)=>(e.relayUrls||r).slice(0,e.relayUrls?e.relayUrls.length:e.relayRedundancy||t))(e,re,3).map((e=>{const r=((e,r)=>{const t={},n=()=>{const a=new WebSocket(e);a.onclose=()=>{P?P.then(n):(v[e]??=3333,setTimeout(n,v[e]),v[e]*=2)},a.onmessage=e=>r(e.data),t.socket=a,t.url=a.url,t.ready=new Promise((r=>a.onopen=()=>{r(t),v[e]=3333})),t.send=e=>{1===a.readyState&&a.send(e)}};return n(),t})(e,(e=>{const r=k(e),n=r["failure reason"],a=r["warning message"],{interval:o}=r,s=G[r.info_hash];if(n)X(t,n,!0);else{if(a&&X(t,a),o&&1e3*o>K[t]&&z[t][s]){const e=Math.min(1e3*o,120333);clearInterval(N[t][s]),K[t]=e,N[t][s]=setInterval(z[t][s],e)}V[r.offer_id]||(r.offer||r.answer)&&(V[r.offer_id]=!0,W[t][s]?.(r))}})),{url:t}=r;return q[t]=r,W[t]={},r.ready}))},subscribe(e,r,t,n,a){const{url:s}=e,i=async()=>{const t=d((await a(10)).map((e=>[o(20),e])));W[e.url][r]=a=>{if(a.offer)n(r,{offer:a.offer,peerId:a.peer_id},((t,n)=>Q(e,r,{answer:k(n).answer,offer_id:a.offer_id,to_peer_id:a.peer_id})));else if(a.answer){const e=t[a.offer_id];e&&n(r,{answer:a.answer,peerId:a.peer_id,peer:e.peer})}},Q(e,r,{numwant:10,offers:l(t).map((([e,{offer:r}])=>({offer_id:e,offer:r})))})};return K[s]=33333,z[s]||={},z[s][r]=i,N[s]||={},N[s][r]=setInterval(i,K[s]),i(),()=>{clearInterval(N[s][r]),delete W[s][r],delete z[s][r]}},announce(e){return K[e.url]}}),Z=(ee=q,()=>d(l(ee).map((([e,r])=>[e,r.socket]))));var ee;const re=["tracker.webtorrent.dev","tracker.openwebtorrent.com","tracker.btorrent.xyz","tracker.files.fm:7073/announce"].map((e=>"wss://"+e));export{re as defaultRelayUrls,Z as getRelaySockets,Y as joinRoom,A as pauseRelayReconnection,S as resumeRelayReconnection,s as selfId};
pear-app/public/tweetnacl.js +1 -0
……@@ -0,0 +1 @@
1!function(i){"use strict";var v=function(r){var t,n=new Float64Array(16);if(r)for(t=0;t<r.length;t++)n[t]=r[t];return n},h=function(){throw new Error("no PRNG")},o=new Uint8Array(16),n=new Uint8Array(32);n[0]=9;var s=v(),u=v([1]),p=v([56129,1]),c=v([30883,4953,19914,30187,55467,16705,2637,112,59544,30585,16505,36039,65139,11119,27886,20995]),y=v([61785,9906,39828,60374,45398,33411,5274,224,53552,61171,33010,6542,64743,22239,55772,9222]),e=v([54554,36645,11616,51542,42930,38181,51040,26924,56412,64982,57905,49316,21502,52590,14035,8553]),a=v([26200,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214,26214]),l=v([41136,18958,6951,50414,58488,44335,6150,12099,55207,15867,153,11085,57099,20417,9344,11139]);function f(r,t,n,e){r[t]=n>>24&255,r[t+1]=n>>16&255,r[t+2]=n>>8&255,r[t+3]=255&n,r[t+4]=e>>24&255,r[t+5]=e>>16&255,r[t+6]=e>>8&255,r[t+7]=255&e}function w(r,t,n,e,o){var i,h=0;for(i=0;i<o;i++)h|=r[t+i]^n[e+i];return(1&h-1>>>8)-1}function b(r,t,n,e){return w(r,t,n,e,16)}function g(r,t,n,e){return w(r,t,n,e,32)}function A(r,t,n,e){!function(r,t,n,e){for(var o,i=255&e[0]|(255&e[1])<<8|(255&e[2])<<16|(255&e[3])<<24,h=255&n[0]|(255&n[1])<<8|(255&n[2])<<16|(255&n[3])<<24,a=255&n[4]|(255&n[5])<<8|(255&n[6])<<16|(255&n[7])<<24,f=255&n[8]|(255&n[9])<<8|(255&n[10])<<16|(255&n[11])<<24,s=255&n[12]|(255&n[13])<<8|(255&n[14])<<16|(255&n[15])<<24,u=255&e[4]|(255&e[5])<<8|(255&e[6])<<16|(255&e[7])<<24,c=255&t[0]|(255&t[1])<<8|(255&t[2])<<16|(255&t[3])<<24,y=255&t[4]|(255&t[5])<<8|(255&t[6])<<16|(255&t[7])<<24,l=255&t[8]|(255&t[9])<<8|(255&t[10])<<16|(255&t[11])<<24,w=255&t[12]|(255&t[13])<<8|(255&t[14])<<16|(255&t[15])<<24,v=255&e[8]|(255&e[9])<<8|(255&e[10])<<16|(255&e[11])<<24,p=255&n[16]|(255&n[17])<<8|(255&n[18])<<16|(255&n[19])<<24,b=255&n[20]|(255&n[21])<<8|(255&n[22])<<16|(255&n[23])<<24,g=255&n[24]|(255&n[25])<<8|(255&n[26])<<16|(255&n[27])<<24,A=255&n[28]|(255&n[29])<<8|(255&n[30])<<16|(255&n[31])<<24,_=255&e[12]|(255&e[13])<<8|(255&e[14])<<16|(255&e[15])<<24,U=i,d=h,E=a,x=f,M=s,m=u,B=c,S=y,k=l,K=w,Y=v,L=p,T=b,z=g,R=A,P=_,N=0;N<20;N+=2)U^=(o=(T^=(o=(k^=(o=(M^=(o=U+T|0)<<7|o>>>25)+U|0)<<9|o>>>23)+M|0)<<13|o>>>19)+k|0)<<18|o>>>14,m^=(o=(d^=(o=(z^=(o=(K^=(o=m+d|0)<<7|o>>>25)+m|0)<<9|o>>>23)+K|0)<<13|o>>>19)+z|0)<<18|o>>>14,Y^=(o=(B^=(o=(E^=(o=(R^=(o=Y+B|0)<<7|o>>>25)+Y|0)<<9|o>>>23)+R|0)<<13|o>>>19)+E|0)<<18|o>>>14,P^=(o=(L^=(o=(S^=(o=(x^=(o=P+L|0)<<7|o>>>25)+P|0)<<9|o>>>23)+x|0)<<13|o>>>19)+S|0)<<18|o>>>14,U^=(o=(x^=(o=(E^=(o=(d^=(o=U+x|0)<<7|o>>>25)+U|0)<<9|o>>>23)+d|0)<<13|o>>>19)+E|0)<<18|o>>>14,m^=(o=(M^=(o=(S^=(o=(B^=(o=m+M|0)<<7|o>>>25)+m|0)<<9|o>>>23)+B|0)<<13|o>>>19)+S|0)<<18|o>>>14,Y^=(o=(K^=(o=(k^=(o=(L^=(o=Y+K|0)<<7|o>>>25)+Y|0)<<9|o>>>23)+L|0)<<13|o>>>19)+k|0)<<18|o>>>14,P^=(o=(R^=(o=(z^=(o=(T^=(o=P+R|0)<<7|o>>>25)+P|0)<<9|o>>>23)+T|0)<<13|o>>>19)+z|0)<<18|o>>>14;U=U+i|0,d=d+h|0,E=E+a|0,x=x+f|0,M=M+s|0,m=m+u|0,B=B+c|0,S=S+y|0,k=k+l|0,K=K+w|0,Y=Y+v|0,L=L+p|0,T=T+b|0,z=z+g|0,R=R+A|0,P=P+_|0,r[0]=U>>>0&255,r[1]=U>>>8&255,r[2]=U>>>16&255,r[3]=U>>>24&255,r[4]=d>>>0&255,r[5]=d>>>8&255,r[6]=d>>>16&255,r[7]=d>>>24&255,r[8]=E>>>0&255,r[9]=E>>>8&255,r[10]=E>>>16&255,r[11]=E>>>24&255,r[12]=x>>>0&255,r[13]=x>>>8&255,r[14]=x>>>16&255,r[15]=x>>>24&255,r[16]=M>>>0&255,r[17]=M>>>8&255,r[18]=M>>>16&255,r[19]=M>>>24&255,r[20]=m>>>0&255,r[21]=m>>>8&255,r[22]=m>>>16&255,r[23]=m>>>24&255,r[24]=B>>>0&255,r[25]=B>>>8&255,r[26]=B>>>16&255,r[27]=B>>>24&255,r[28]=S>>>0&255,r[29]=S>>>8&255,r[30]=S>>>16&255,r[31]=S>>>24&255,r[32]=k>>>0&255,r[33]=k>>>8&255,r[34]=k>>>16&255,r[35]=k>>>24&255,r[36]=K>>>0&255,r[37]=K>>>8&255,r[38]=K>>>16&255,r[39]=K>>>24&255,r[40]=Y>>>0&255,r[41]=Y>>>8&255,r[42]=Y>>>16&255,r[43]=Y>>>24&255,r[44]=L>>>0&255,r[45]=L>>>8&255,r[46]=L>>>16&255,r[47]=L>>>24&255,r[48]=T>>>0&255,r[49]=T>>>8&255,r[50]=T>>>16&255,r[51]=T>>>24&255,r[52]=z>>>0&255,r[53]=z>>>8&255,r[54]=z>>>16&255,r[55]=z>>>24&255,r[56]=R>>>0&255,r[57]=R>>>8&255,r[58]=R>>>16&255,r[59]=R>>>24&255,r[60]=P>>>0&255,r[61]=P>>>8&255,r[62]=P>>>16&255,r[63]=P>>>24&255}(r,t,n,e)}function _(r,t,n,e){!function(r,t,n,e){for(var o,i=255&e[0]|(255&e[1])<<8|(255&e[2])<<16|(255&e[3])<<24,h=255&n[0]|(255&n[1])<<8|(255&n[2])<<16|(255&n[3])<<24,a=255&n[4]|(255&n[5])<<8|(255&n[6])<<16|(255&n[7])<<24,f=255&n[8]|(255&n[9])<<8|(255&n[10])<<16|(255&n[11])<<24,s=255&n[12]|(255&n[13])<<8|(255&n[14])<<16|(255&n[15])<<24,u=255&e[4]|(255&e[5])<<8|(255&e[6])<<16|(255&e[7])<<24,c=255&t[0]|(255&t[1])<<8|(255&t[2])<<16|(255&t[3])<<24,y=255&t[4]|(255&t[5])<<8|(255&t[6])<<16|(255&t[7])<<24,l=255&t[8]|(255&t[9])<<8|(255&t[10])<<16|(255&t[11])<<24,w=255&t[12]|(255&t[13])<<8|(255&t[14])<<16|(255&t[15])<<24,v=255&e[8]|(255&e[9])<<8|(255&e[10])<<16|(255&e[11])<<24,p=255&n[16]|(255&n[17])<<8|(255&n[18])<<16|(255&n[19])<<24,b=255&n[20]|(255&n[21])<<8|(255&n[22])<<16|(255&n[23])<<24,g=255&n[24]|(255&n[25])<<8|(255&n[26])<<16|(255&n[27])<<24,A=255&n[28]|(255&n[29])<<8|(255&n[30])<<16|(255&n[31])<<24,_=255&e[12]|(255&e[13])<<8|(255&e[14])<<16|(255&e[15])<<24,U=0;U<20;U+=2)i^=(o=(b^=(o=(l^=(o=(s^=(o=i+b|0)<<7|o>>>25)+i|0)<<9|o>>>23)+s|0)<<13|o>>>19)+l|0)<<18|o>>>14,u^=(o=(h^=(o=(g^=(o=(w^=(o=u+h|0)<<7|o>>>25)+u|0)<<9|o>>>23)+w|0)<<13|o>>>19)+g|0)<<18|o>>>14,v^=(o=(c^=(o=(a^=(o=(A^=(o=v+c|0)<<7|o>>>25)+v|0)<<9|o>>>23)+A|0)<<13|o>>>19)+a|0)<<18|o>>>14,_^=(o=(p^=(o=(y^=(o=(f^=(o=_+p|0)<<7|o>>>25)+_|0)<<9|o>>>23)+f|0)<<13|o>>>19)+y|0)<<18|o>>>14,i^=(o=(f^=(o=(a^=(o=(h^=(o=i+f|0)<<7|o>>>25)+i|0)<<9|o>>>23)+h|0)<<13|o>>>19)+a|0)<<18|o>>>14,u^=(o=(s^=(o=(y^=(o=(c^=(o=u+s|0)<<7|o>>>25)+u|0)<<9|o>>>23)+c|0)<<13|o>>>19)+y|0)<<18|o>>>14,v^=(o=(w^=(o=(l^=(o=(p^=(o=v+w|0)<<7|o>>>25)+v|0)<<9|o>>>23)+p|0)<<13|o>>>19)+l|0)<<18|o>>>14,_^=(o=(A^=(o=(g^=(o=(b^=(o=_+A|0)<<7|o>>>25)+_|0)<<9|o>>>23)+b|0)<<13|o>>>19)+g|0)<<18|o>>>14;r[0]=i>>>0&255,r[1]=i>>>8&255,r[2]=i>>>16&255,r[3]=i>>>24&255,r[4]=u>>>0&255,r[5]=u>>>8&255,r[6]=u>>>16&255,r[7]=u>>>24&255,r[8]=v>>>0&255,r[9]=v>>>8&255,r[10]=v>>>16&255,r[11]=v>>>24&255,r[12]=_>>>0&255,r[13]=_>>>8&255,r[14]=_>>>16&255,r[15]=_>>>24&255,r[16]=c>>>0&255,r[17]=c>>>8&255,r[18]=c>>>16&255,r[19]=c>>>24&255,r[20]=y>>>0&255,r[21]=y>>>8&255,r[22]=y>>>16&255,r[23]=y>>>24&255,r[24]=l>>>0&255,r[25]=l>>>8&255,r[26]=l>>>16&255,r[27]=l>>>24&255,r[28]=w>>>0&255,r[29]=w>>>8&255,r[30]=w>>>16&255,r[31]=w>>>24&255}(r,t,n,e)}var U=new Uint8Array([101,120,112,97,110,100,32,51,50,45,98,121,116,101,32,107]);function d(r,t,n,e,o,i,h){var a,f,s=new Uint8Array(16),u=new Uint8Array(64);for(f=0;f<16;f++)s[f]=0;for(f=0;f<8;f++)s[f]=i[f];for(;64<=o;){for(A(u,s,h,U),f=0;f<64;f++)r[t+f]=n[e+f]^u[f];for(a=1,f=8;f<16;f++)a=a+(255&s[f])|0,s[f]=255&a,a>>>=8;o-=64,t+=64,e+=64}if(0<o)for(A(u,s,h,U),f=0;f<o;f++)r[t+f]=n[e+f]^u[f];return 0}function E(r,t,n,e,o){var i,h,a=new Uint8Array(16),f=new Uint8Array(64);for(h=0;h<16;h++)a[h]=0;for(h=0;h<8;h++)a[h]=e[h];for(;64<=n;){for(A(f,a,o,U),h=0;h<64;h++)r[t+h]=f[h];for(i=1,h=8;h<16;h++)i=i+(255&a[h])|0,a[h]=255&i,i>>>=8;n-=64,t+=64}if(0<n)for(A(f,a,o,U),h=0;h<n;h++)r[t+h]=f[h];return 0}function x(r,t,n,e,o){var i=new Uint8Array(32);_(i,e,o,U);for(var h=new Uint8Array(8),a=0;a<8;a++)h[a]=e[a+16];return E(r,t,n,h,i)}function M(r,t,n,e,o,i,h){var a=new Uint8Array(32);_(a,i,h,U);for(var f=new Uint8Array(8),s=0;s<8;s++)f[s]=i[s+16];return d(r,t,n,e,o,f,a)}var m=function(r){var t,n,e,o,i,h,a,f;this.buffer=new Uint8Array(16),this.r=new Uint16Array(10),this.h=new Uint16Array(10),this.pad=new Uint16Array(8),this.leftover=0,t=255&r[this.fin=0]|(255&r[1])<<8,this.r[0]=8191&t,n=255&r[2]|(255&r[3])<<8,this.r[1]=8191&(t>>>13|n<<3),e=255&r[4]|(255&r[5])<<8,this.r[2]=7939&(n>>>10|e<<6),o=255&r[6]|(255&r[7])<<8,this.r[3]=8191&(e>>>7|o<<9),i=255&r[8]|(255&r[9])<<8,this.r[4]=255&(o>>>4|i<<12),this.r[5]=i>>>1&8190,h=255&r[10]|(255&r[11])<<8,this.r[6]=8191&(i>>>14|h<<2),a=255&r[12]|(255&r[13])<<8,this.r[7]=8065&(h>>>11|a<<5),f=255&r[14]|(255&r[15])<<8,this.r[8]=8191&(a>>>8|f<<8),this.r[9]=f>>>5&127,this.pad[0]=255&r[16]|(255&r[17])<<8,this.pad[1]=255&r[18]|(255&r[19])<<8,this.pad[2]=255&r[20]|(255&r[21])<<8,this.pad[3]=255&r[22]|(255&r[23])<<8,this.pad[4]=255&r[24]|(255&r[25])<<8,this.pad[5]=255&r[26]|(255&r[27])<<8,this.pad[6]=255&r[28]|(255&r[29])<<8,this.pad[7]=255&r[30]|(255&r[31])<<8};function B(r,t,n,e,o,i){var h=new m(i);return h.update(n,e,o),h.finish(r,t),0}function S(r,t,n,e,o,i){var h=new Uint8Array(16);return B(h,0,n,e,o,i),b(r,t,h,0)}function k(r,t,n,e,o){var i;if(n<32)return-1;for(M(r,0,t,0,n,e,o),B(r,16,r,32,n-32,r),i=0;i<16;i++)r[i]=0;return 0}function K(r,t,n,e,o){var i,h=new Uint8Array(32);if(n<32)return-1;if(x(h,0,32,e,o),0!==S(t,16,t,32,n-32,h))return-1;for(M(r,0,t,0,n,e,o),i=0;i<32;i++)r[i]=0;return 0}function Y(r,t){var n;for(n=0;n<16;n++)r[n]=0|t[n]}function L(r){var t,n,e=1;for(t=0;t<16;t++)n=r[t]+e+65535,e=Math.floor(n/65536),r[t]=n-65536*e;r[0]+=e-1+37*(e-1)}function T(r,t,n){for(var e,o=~(n-1),i=0;i<16;i++)e=o&(r[i]^t[i]),r[i]^=e,t[i]^=e}function z(r,t){var n,e,o,i=v(),h=v();for(n=0;n<16;n++)h[n]=t[n];for(L(h),L(h),L(h),e=0;e<2;e++){for(i[0]=h[0]-65517,n=1;n<15;n++)i[n]=h[n]-65535-(i[n-1]>>16&1),i[n-1]&=65535;i[15]=h[15]-32767-(i[14]>>16&1),o=i[15]>>16&1,i[14]&=65535,T(h,i,1-o)}for(n=0;n<16;n++)r[2*n]=255&h[n],r[2*n+1]=h[n]>>8}function R(r,t){var n=new Uint8Array(32),e=new Uint8Array(32);return z(n,r),z(e,t),g(n,0,e,0)}function P(r){var t=new Uint8Array(32);return z(t,r),1&t[0]}function N(r,t){var n;for(n=0;n<16;n++)r[n]=t[2*n]+(t[2*n+1]<<8);r[15]&=32767}function O(r,t,n){for(var e=0;e<16;e++)r[e]=t[e]+n[e]}function C(r,t,n){for(var e=0;e<16;e++)r[e]=t[e]-n[e]}function F(r,t,n){var e,o,i=0,h=0,a=0,f=0,s=0,u=0,c=0,y=0,l=0,w=0,v=0,p=0,b=0,g=0,A=0,_=0,U=0,d=0,E=0,x=0,M=0,m=0,B=0,S=0,k=0,K=0,Y=0,L=0,T=0,z=0,R=0,P=n[0],N=n[1],O=n[2],C=n[3],F=n[4],I=n[5],Z=n[6],G=n[7],q=n[8],D=n[9],V=n[10],X=n[11],j=n[12],H=n[13],J=n[14],Q=n[15];i+=(e=t[0])*P,h+=e*N,a+=e*O,f+=e*C,s+=e*F,u+=e*I,c+=e*Z,y+=e*G,l+=e*q,w+=e*D,v+=e*V,p+=e*X,b+=e*j,g+=e*H,A+=e*J,_+=e*Q,h+=(e=t[1])*P,a+=e*N,f+=e*O,s+=e*C,u+=e*F,c+=e*I,y+=e*Z,l+=e*G,w+=e*q,v+=e*D,p+=e*V,b+=e*X,g+=e*j,A+=e*H,_+=e*J,U+=e*Q,a+=(e=t[2])*P,f+=e*N,s+=e*O,u+=e*C,c+=e*F,y+=e*I,l+=e*Z,w+=e*G,v+=e*q,p+=e*D,b+=e*V,g+=e*X,A+=e*j,_+=e*H,U+=e*J,d+=e*Q,f+=(e=t[3])*P,s+=e*N,u+=e*O,c+=e*C,y+=e*F,l+=e*I,w+=e*Z,v+=e*G,p+=e*q,b+=e*D,g+=e*V,A+=e*X,_+=e*j,U+=e*H,d+=e*J,E+=e*Q,s+=(e=t[4])*P,u+=e*N,c+=e*O,y+=e*C,l+=e*F,w+=e*I,v+=e*Z,p+=e*G,b+=e*q,g+=e*D,A+=e*V,_+=e*X,U+=e*j,d+=e*H,E+=e*J,x+=e*Q,u+=(e=t[5])*P,c+=e*N,y+=e*O,l+=e*C,w+=e*F,v+=e*I,p+=e*Z,b+=e*G,g+=e*q,A+=e*D,_+=e*V,U+=e*X,d+=e*j,E+=e*H,x+=e*J,M+=e*Q,c+=(e=t[6])*P,y+=e*N,l+=e*O,w+=e*C,v+=e*F,p+=e*I,b+=e*Z,g+=e*G,A+=e*q,_+=e*D,U+=e*V,d+=e*X,E+=e*j,x+=e*H,M+=e*J,m+=e*Q,y+=(e=t[7])*P,l+=e*N,w+=e*O,v+=e*C,p+=e*F,b+=e*I,g+=e*Z,A+=e*G,_+=e*q,U+=e*D,d+=e*V,E+=e*X,x+=e*j,M+=e*H,m+=e*J,B+=e*Q,l+=(e=t[8])*P,w+=e*N,v+=e*O,p+=e*C,b+=e*F,g+=e*I,A+=e*Z,_+=e*G,U+=e*q,d+=e*D,E+=e*V,x+=e*X,M+=e*j,m+=e*H,B+=e*J,S+=e*Q,w+=(e=t[9])*P,v+=e*N,p+=e*O,b+=e*C,g+=e*F,A+=e*I,_+=e*Z,U+=e*G,d+=e*q,E+=e*D,x+=e*V,M+=e*X,m+=e*j,B+=e*H,S+=e*J,k+=e*Q,v+=(e=t[10])*P,p+=e*N,b+=e*O,g+=e*C,A+=e*F,_+=e*I,U+=e*Z,d+=e*G,E+=e*q,x+=e*D,M+=e*V,m+=e*X,B+=e*j,S+=e*H,k+=e*J,K+=e*Q,p+=(e=t[11])*P,b+=e*N,g+=e*O,A+=e*C,_+=e*F,U+=e*I,d+=e*Z,E+=e*G,x+=e*q,M+=e*D,m+=e*V,B+=e*X,S+=e*j,k+=e*H,K+=e*J,Y+=e*Q,b+=(e=t[12])*P,g+=e*N,A+=e*O,_+=e*C,U+=e*F,d+=e*I,E+=e*Z,x+=e*G,M+=e*q,m+=e*D,B+=e*V,S+=e*X,k+=e*j,K+=e*H,Y+=e*J,L+=e*Q,g+=(e=t[13])*P,A+=e*N,_+=e*O,U+=e*C,d+=e*F,E+=e*I,x+=e*Z,M+=e*G,m+=e*q,B+=e*D,S+=e*V,k+=e*X,K+=e*j,Y+=e*H,L+=e*J,T+=e*Q,A+=(e=t[14])*P,_+=e*N,U+=e*O,d+=e*C,E+=e*F,x+=e*I,M+=e*Z,m+=e*G,B+=e*q,S+=e*D,k+=e*V,K+=e*X,Y+=e*j,L+=e*H,T+=e*J,z+=e*Q,_+=(e=t[15])*P,h+=38*(d+=e*O),a+=38*(E+=e*C),f+=38*(x+=e*F),s+=38*(M+=e*I),u+=38*(m+=e*Z),c+=38*(B+=e*G),y+=38*(S+=e*q),l+=38*(k+=e*D),w+=38*(K+=e*V),v+=38*(Y+=e*X),p+=38*(L+=e*j),b+=38*(T+=e*H),g+=38*(z+=e*J),A+=38*(R+=e*Q),i=(e=(i+=38*(U+=e*N))+(o=1)+65535)-65536*(o=Math.floor(e/65536)),h=(e=h+o+65535)-65536*(o=Math.floor(e/65536)),a=(e=a+o+65535)-65536*(o=Math.floor(e/65536)),f=(e=f+o+65535)-65536*(o=Math.floor(e/65536)),s=(e=s+o+65535)-65536*(o=Math.floor(e/65536)),u=(e=u+o+65535)-65536*(o=Math.floor(e/65536)),c=(e=c+o+65535)-65536*(o=Math.floor(e/65536)),y=(e=y+o+65535)-65536*(o=Math.floor(e/65536)),l=(e=l+o+65535)-65536*(o=Math.floor(e/65536)),w=(e=w+o+65535)-65536*(o=Math.floor(e/65536)),v=(e=v+o+65535)-65536*(o=Math.floor(e/65536)),p=(e=p+o+65535)-65536*(o=Math.floor(e/65536)),b=(e=b+o+65535)-65536*(o=Math.floor(e/65536)),g=(e=g+o+65535)-65536*(o=Math.floor(e/65536)),A=(e=A+o+65535)-65536*(o=Math.floor(e/65536)),_=(e=_+o+65535)-65536*(o=Math.floor(e/65536)),i=(e=(i+=o-1+37*(o-1))+(o=1)+65535)-65536*(o=Math.floor(e/65536)),h=(e=h+o+65535)-65536*(o=Math.floor(e/65536)),a=(e=a+o+65535)-65536*(o=Math.floor(e/65536)),f=(e=f+o+65535)-65536*(o=Math.floor(e/65536)),s=(e=s+o+65535)-65536*(o=Math.floor(e/65536)),u=(e=u+o+65535)-65536*(o=Math.floor(e/65536)),c=(e=c+o+65535)-65536*(o=Math.floor(e/65536)),y=(e=y+o+65535)-65536*(o=Math.floor(e/65536)),l=(e=l+o+65535)-65536*(o=Math.floor(e/65536)),w=(e=w+o+65535)-65536*(o=Math.floor(e/65536)),v=(e=v+o+65535)-65536*(o=Math.floor(e/65536)),p=(e=p+o+65535)-65536*(o=Math.floor(e/65536)),b=(e=b+o+65535)-65536*(o=Math.floor(e/65536)),g=(e=g+o+65535)-65536*(o=Math.floor(e/65536)),A=(e=A+o+65535)-65536*(o=Math.floor(e/65536)),_=(e=_+o+65535)-65536*(o=Math.floor(e/65536)),i+=o-1+37*(o-1),r[0]=i,r[1]=h,r[2]=a,r[3]=f,r[4]=s,r[5]=u,r[6]=c,r[7]=y,r[8]=l,r[9]=w,r[10]=v,r[11]=p,r[12]=b,r[13]=g,r[14]=A,r[15]=_}function I(r,t){F(r,t,t)}function Z(r,t){var n,e=v();for(n=0;n<16;n++)e[n]=t[n];for(n=253;0<=n;n--)I(e,e),2!==n&&4!==n&&F(e,e,t);for(n=0;n<16;n++)r[n]=e[n]}function G(r,t){var n,e=v();for(n=0;n<16;n++)e[n]=t[n];for(n=250;0<=n;n--)I(e,e),1!==n&&F(e,e,t);for(n=0;n<16;n++)r[n]=e[n]}function q(r,t,n){var e,o,i=new Uint8Array(32),h=new Float64Array(80),a=v(),f=v(),s=v(),u=v(),c=v(),y=v();for(o=0;o<31;o++)i[o]=t[o];for(i[31]=127&t[31]|64,i[0]&=248,N(h,n),o=0;o<16;o++)f[o]=h[o],u[o]=a[o]=s[o]=0;for(a[0]=u[0]=1,o=254;0<=o;--o)T(a,f,e=i[o>>>3]>>>(7&o)&1),T(s,u,e),O(c,a,s),C(a,a,s),O(s,f,u),C(f,f,u),I(u,c),I(y,a),F(a,s,a),F(s,f,c),O(c,a,s),C(a,a,s),I(f,a),C(s,u,y),F(a,s,p),O(a,a,u),F(s,s,a),F(a,u,y),F(u,f,h),I(f,c),T(a,f,e),T(s,u,e);for(o=0;o<16;o++)h[o+16]=a[o],h[o+32]=s[o],h[o+48]=f[o],h[o+64]=u[o];var l=h.subarray(32),w=h.subarray(16);return Z(l,l),F(w,w,l),z(r,w),0}function D(r,t){return q(r,t,n)}function V(r,t){return h(t,32),D(r,t)}function X(r,t,n){var e=new Uint8Array(32);return q(e,n,t),_(r,o,e,U)}m.prototype.blocks=function(r,t,n){for(var e,o,i,h,a,f,s,u,c,y,l,w,v,p,b,g,A,_,U,d=this.fin?0:2048,E=this.h[0],x=this.h[1],M=this.h[2],m=this.h[3],B=this.h[4],S=this.h[5],k=this.h[6],K=this.h[7],Y=this.h[8],L=this.h[9],T=this.r[0],z=this.r[1],R=this.r[2],P=this.r[3],N=this.r[4],O=this.r[5],C=this.r[6],F=this.r[7],I=this.r[8],Z=this.r[9];16<=n;)y=c=0,y+=(E+=8191&(e=255&r[t+0]|(255&r[t+1])<<8))*T,y+=(x+=8191&(e>>>13|(o=255&r[t+2]|(255&r[t+3])<<8)<<3))*(5*Z),y+=(M+=8191&(o>>>10|(i=255&r[t+4]|(255&r[t+5])<<8)<<6))*(5*I),y+=(m+=8191&(i>>>7|(h=255&r[t+6]|(255&r[t+7])<<8)<<9))*(5*F),c=(y+=(B+=8191&(h>>>4|(a=255&r[t+8]|(255&r[t+9])<<8)<<12))*(5*C))>>>13,y&=8191,y+=(S+=a>>>1&8191)*(5*O),y+=(k+=8191&(a>>>14|(f=255&r[t+10]|(255&r[t+11])<<8)<<2))*(5*N),y+=(K+=8191&(f>>>11|(s=255&r[t+12]|(255&r[t+13])<<8)<<5))*(5*P),y+=(Y+=8191&(s>>>8|(u=255&r[t+14]|(255&r[t+15])<<8)<<8))*(5*R),l=c+=(y+=(L+=u>>>5|d)*(5*z))>>>13,l+=E*z,l+=x*T,l+=M*(5*Z),l+=m*(5*I),c=(l+=B*(5*F))>>>13,l&=8191,l+=S*(5*C),l+=k*(5*O),l+=K*(5*N),l+=Y*(5*P),c+=(l+=L*(5*R))>>>13,l&=8191,w=c,w+=E*R,w+=x*z,w+=M*T,w+=m*(5*Z),c=(w+=B*(5*I))>>>13,w&=8191,w+=S*(5*F),w+=k*(5*C),w+=K*(5*O),w+=Y*(5*N),v=c+=(w+=L*(5*P))>>>13,v+=E*P,v+=x*R,v+=M*z,v+=m*T,c=(v+=B*(5*Z))>>>13,v&=8191,v+=S*(5*I),v+=k*(5*F),v+=K*(5*C),v+=Y*(5*O),p=c+=(v+=L*(5*N))>>>13,p+=E*N,p+=x*P,p+=M*R,p+=m*z,c=(p+=B*T)>>>13,p&=8191,p+=S*(5*Z),p+=k*(5*I),p+=K*(5*F),p+=Y*(5*C),b=c+=(p+=L*(5*O))>>>13,b+=E*O,b+=x*N,b+=M*P,b+=m*R,c=(b+=B*z)>>>13,b&=8191,b+=S*T,b+=k*(5*Z),b+=K*(5*I),b+=Y*(5*F),g=c+=(b+=L*(5*C))>>>13,g+=E*C,g+=x*O,g+=M*N,g+=m*P,c=(g+=B*R)>>>13,g&=8191,g+=S*z,g+=k*T,g+=K*(5*Z),g+=Y*(5*I),A=c+=(g+=L*(5*F))>>>13,A+=E*F,A+=x*C,A+=M*O,A+=m*N,c=(A+=B*P)>>>13,A&=8191,A+=S*R,A+=k*z,A+=K*T,A+=Y*(5*Z),_=c+=(A+=L*(5*I))>>>13,_+=E*I,_+=x*F,_+=M*C,_+=m*O,c=(_+=B*N)>>>13,_&=8191,_+=S*P,_+=k*R,_+=K*z,_+=Y*T,U=c+=(_+=L*(5*Z))>>>13,U+=E*Z,U+=x*I,U+=M*F,U+=m*C,c=(U+=B*O)>>>13,U&=8191,U+=S*N,U+=k*P,U+=K*R,U+=Y*z,E=y=8191&(c=(c=((c+=(U+=L*T)>>>13)<<2)+c|0)+(y&=8191)|0),x=l+=c>>>=13,M=w&=8191,m=v&=8191,B=p&=8191,S=b&=8191,k=g&=8191,K=A&=8191,Y=_&=8191,L=U&=8191,t+=16,n-=16;this.h[0]=E,this.h[1]=x,this.h[2]=M,this.h[3]=m,this.h[4]=B,this.h[5]=S,this.h[6]=k,this.h[7]=K,this.h[8]=Y,this.h[9]=L},m.prototype.finish=function(r,t){var n,e,o,i,h=new Uint16Array(10);if(this.leftover){for(i=this.leftover,this.buffer[i++]=1;i<16;i++)this.buffer[i]=0;this.fin=1,this.blocks(this.buffer,0,16)}for(n=this.h[1]>>>13,this.h[1]&=8191,i=2;i<10;i++)this.h[i]+=n,n=this.h[i]>>>13,this.h[i]&=8191;for(this.h[0]+=5*n,n=this.h[0]>>>13,this.h[0]&=8191,this.h[1]+=n,n=this.h[1]>>>13,this.h[1]&=8191,this.h[2]+=n,h[0]=this.h[0]+5,n=h[0]>>>13,h[0]&=8191,i=1;i<10;i++)h[i]=this.h[i]+n,n=h[i]>>>13,h[i]&=8191;for(h[9]-=8192,e=(1^n)-1,i=0;i<10;i++)h[i]&=e;for(e=~e,i=0;i<10;i++)this.h[i]=this.h[i]&e|h[i];for(this.h[0]=65535&(this.h[0]|this.h[1]<<13),this.h[1]=65535&(this.h[1]>>>3|this.h[2]<<10),this.h[2]=65535&(this.h[2]>>>6|this.h[3]<<7),this.h[3]=65535&(this.h[3]>>>9|this.h[4]<<4),this.h[4]=65535&(this.h[4]>>>12|this.h[5]<<1|this.h[6]<<14),this.h[5]=65535&(this.h[6]>>>2|this.h[7]<<11),this.h[6]=65535&(this.h[7]>>>5|this.h[8]<<8),this.h[7]=65535&(this.h[8]>>>8|this.h[9]<<5),o=this.h[0]+this.pad[0],this.h[0]=65535&o,i=1;i<8;i++)o=(this.h[i]+this.pad[i]|0)+(o>>>16)|0,this.h[i]=65535&o;r[t+0]=this.h[0]>>>0&255,r[t+1]=this.h[0]>>>8&255,r[t+2]=this.h[1]>>>0&255,r[t+3]=this.h[1]>>>8&255,r[t+4]=this.h[2]>>>0&255,r[t+5]=this.h[2]>>>8&255,r[t+6]=this.h[3]>>>0&255,r[t+7]=this.h[3]>>>8&255,r[t+8]=this.h[4]>>>0&255,r[t+9]=this.h[4]>>>8&255,r[t+10]=this.h[5]>>>0&255,r[t+11]=this.h[5]>>>8&255,r[t+12]=this.h[6]>>>0&255,r[t+13]=this.h[6]>>>8&255,r[t+14]=this.h[7]>>>0&255,r[t+15]=this.h[7]>>>8&255},m.prototype.update=function(r,t,n){var e,o;if(this.leftover){for(n<(o=16-this.leftover)&&(o=n),e=0;e<o;e++)this.buffer[this.leftover+e]=r[t+e];if(n-=o,t+=o,this.leftover+=o,this.leftover<16)return;this.blocks(this.buffer,0,16),this.leftover=0}if(16<=n&&(o=n-n%16,this.blocks(r,t,o),t+=o,n-=o),n){for(e=0;e<n;e++)this.buffer[this.leftover+e]=r[t+e];this.leftover+=n}};var j=k,H=K;var J=[1116352408,3609767458,1899447441,602891725,3049323471,3964484399,3921009573,2173295548,961987163,4081628472,1508970993,3053834265,2453635748,2937671579,2870763221,3664609560,3624381080,2734883394,310598401,1164996542,607225278,1323610764,1426881987,3590304994,1925078388,4068182383,2162078206,991336113,2614888103,633803317,3248222580,3479774868,3835390401,2666613458,4022224774,944711139,264347078,2341262773,604807628,2007800933,770255983,1495990901,1249150122,1856431235,1555081692,3175218132,1996064986,2198950837,2554220882,3999719339,2821834349,766784016,2952996808,2566594879,3210313671,3203337956,3336571891,1034457026,3584528711,2466948901,113926993,3758326383,338241895,168717936,666307205,1188179964,773529912,1546045734,1294757372,1522805485,1396182291,2643833823,1695183700,2343527390,1986661051,1014477480,2177026350,1206759142,2456956037,344077627,2730485921,1290863460,2820302411,3158454273,3259730800,3505952657,3345764771,106217008,3516065817,3606008344,3600352804,1432725776,4094571909,1467031594,275423344,851169720,430227734,3100823752,506948616,1363258195,659060556,3750685593,883997877,3785050280,958139571,3318307427,1322822218,3812723403,1537002063,2003034995,1747873779,3602036899,1955562222,1575990012,2024104815,1125592928,2227730452,2716904306,2361852424,442776044,2428436474,593698344,2756734187,3733110249,3204031479,2999351573,3329325298,3815920427,3391569614,3928383900,3515267271,566280711,3940187606,3454069534,4118630271,4000239992,116418474,1914138554,174292421,2731055270,289380356,3203993006,460393269,320620315,685471733,587496836,852142971,1086792851,1017036298,365543100,1126000580,2618297676,1288033470,3409855158,1501505948,4234509866,1607167915,987167468,1816402316,1246189591];function Q(r,t,n,e){for(var o,i,h,a,f,s,u,c,y,l,w,v,p,b,g,A,_,U,d,E,x,M,m,B,S=new Int32Array(16),k=new Int32Array(16),K=r[0],Y=r[1],L=r[2],T=r[3],z=r[4],R=r[5],P=r[6],N=r[7],O=t[0],C=t[1],F=t[2],I=t[3],Z=t[4],G=t[5],q=t[6],D=t[7],V=0;128<=e;){for(_=0;_<16;_++)U=8*_+V,S[_]=n[U+0]<<24|n[U+1]<<16|n[U+2]<<8|n[U+3],k[_]=n[U+4]<<24|n[U+5]<<16|n[U+6]<<8|n[U+7];for(_=0;_<80;_++)if(o=Y,i=L,h=T,c=C,y=F,l=I,x=65535&(E=D),M=E>>>16,m=65535&(d=N),B=d>>>16,x+=65535&(E=((w=Z)>>>14|(a=z)<<18)^(Z>>>18|z<<14)^(z>>>9|Z<<23)),M+=E>>>16,m+=65535&(d=(z>>>14|Z<<18)^(z>>>18|Z<<14)^(Z>>>9|z<<23)),B+=d>>>16,x+=65535&(E=Z&(v=G)^~Z&(p=q)),M+=E>>>16,m+=65535&(d=z&(f=R)^~z&(s=P)),B+=d>>>16,d=J[2*_],x+=65535&(E=J[2*_+1]),M+=E>>>16,m+=65535&d,B+=d>>>16,d=S[_%16],M+=(E=k[_%16])>>>16,m+=65535&d,B+=d>>>16,m+=(M+=(x+=65535&E)>>>16)>>>16,x=65535&(E=A=65535&x|M<<16),M=E>>>16,m=65535&(d=g=65535&m|(B+=m>>>16)<<16),B=d>>>16,x+=65535&(E=(O>>>28|K<<4)^(K>>>2|O<<30)^(K>>>7|O<<25)),M+=E>>>16,m+=65535&(d=(K>>>28|O<<4)^(O>>>2|K<<30)^(O>>>7|K<<25)),B+=d>>>16,M+=(E=O&C^O&F^C&F)>>>16,m+=65535&(d=K&Y^K&L^Y&L),B+=d>>>16,u=65535&(m+=(M+=(x+=65535&E)>>>16)>>>16)|(B+=m>>>16)<<16,b=65535&x|M<<16,x=65535&(E=l),M=E>>>16,m=65535&(d=h),B=d>>>16,M+=(E=A)>>>16,m+=65535&(d=g),B+=d>>>16,Y=K,L=o,T=i,z=h=65535&(m+=(M+=(x+=65535&E)>>>16)>>>16)|(B+=m>>>16)<<16,R=a,P=f,N=s,K=u,C=O,F=c,I=y,Z=l=65535&x|M<<16,G=w,q=v,D=p,O=b,_%16==15)for(U=0;U<16;U++)d=S[U],x=65535&(E=k[U]),M=E>>>16,m=65535&d,B=d>>>16,d=S[(U+9)%16],x+=65535&(E=k[(U+9)%16]),M+=E>>>16,m+=65535&d,B+=d>>>16,g=S[(U+1)%16],x+=65535&(E=((A=k[(U+1)%16])>>>1|g<<31)^(A>>>8|g<<24)^(A>>>7|g<<25)),M+=E>>>16,m+=65535&(d=(g>>>1|A<<31)^(g>>>8|A<<24)^g>>>7),B+=d>>>16,g=S[(U+14)%16],M+=(E=((A=k[(U+14)%16])>>>19|g<<13)^(g>>>29|A<<3)^(A>>>6|g<<26))>>>16,m+=65535&(d=(g>>>19|A<<13)^(A>>>29|g<<3)^g>>>6),B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,S[U]=65535&m|B<<16,k[U]=65535&x|M<<16;x=65535&(E=O),M=E>>>16,m=65535&(d=K),B=d>>>16,d=r[0],M+=(E=t[0])>>>16,m+=65535&d,B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,r[0]=K=65535&m|B<<16,t[0]=O=65535&x|M<<16,x=65535&(E=C),M=E>>>16,m=65535&(d=Y),B=d>>>16,d=r[1],M+=(E=t[1])>>>16,m+=65535&d,B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,r[1]=Y=65535&m|B<<16,t[1]=C=65535&x|M<<16,x=65535&(E=F),M=E>>>16,m=65535&(d=L),B=d>>>16,d=r[2],M+=(E=t[2])>>>16,m+=65535&d,B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,r[2]=L=65535&m|B<<16,t[2]=F=65535&x|M<<16,x=65535&(E=I),M=E>>>16,m=65535&(d=T),B=d>>>16,d=r[3],M+=(E=t[3])>>>16,m+=65535&d,B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,r[3]=T=65535&m|B<<16,t[3]=I=65535&x|M<<16,x=65535&(E=Z),M=E>>>16,m=65535&(d=z),B=d>>>16,d=r[4],M+=(E=t[4])>>>16,m+=65535&d,B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,r[4]=z=65535&m|B<<16,t[4]=Z=65535&x|M<<16,x=65535&(E=G),M=E>>>16,m=65535&(d=R),B=d>>>16,d=r[5],M+=(E=t[5])>>>16,m+=65535&d,B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,r[5]=R=65535&m|B<<16,t[5]=G=65535&x|M<<16,x=65535&(E=q),M=E>>>16,m=65535&(d=P),B=d>>>16,d=r[6],M+=(E=t[6])>>>16,m+=65535&d,B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,r[6]=P=65535&m|B<<16,t[6]=q=65535&x|M<<16,x=65535&(E=D),M=E>>>16,m=65535&(d=N),B=d>>>16,d=r[7],M+=(E=t[7])>>>16,m+=65535&d,B+=d>>>16,B+=(m+=(M+=(x+=65535&E)>>>16)>>>16)>>>16,r[7]=N=65535&m|B<<16,t[7]=D=65535&x|M<<16,V+=128,e-=128}return e}function W(r,t,n){var e,o=new Int32Array(8),i=new Int32Array(8),h=new Uint8Array(256),a=n;for(o[0]=1779033703,o[1]=3144134277,o[2]=1013904242,o[3]=2773480762,o[4]=1359893119,o[5]=2600822924,o[6]=528734635,o[7]=1541459225,i[0]=4089235720,i[1]=2227873595,i[2]=4271175723,i[3]=1595750129,i[4]=2917565137,i[5]=725511199,i[6]=4215389547,i[7]=327033209,Q(o,i,t,n),n%=128,e=0;e<n;e++)h[e]=t[a-n+e];for(h[n]=128,h[(n=256-128*(n<112?1:0))-9]=0,f(h,n-8,a/536870912|0,a<<3),Q(o,i,h,n),e=0;e<8;e++)f(r,8*e,o[e],i[e]);return 0}function $(r,t){var n=v(),e=v(),o=v(),i=v(),h=v(),a=v(),f=v(),s=v(),u=v();C(n,r[1],r[0]),C(u,t[1],t[0]),F(n,n,u),O(e,r[0],r[1]),O(u,t[0],t[1]),F(e,e,u),F(o,r[3],t[3]),F(o,o,y),F(i,r[2],t[2]),O(i,i,i),C(h,e,n),C(a,i,o),O(f,i,o),O(s,e,n),F(r[0],h,a),F(r[1],s,f),F(r[2],f,a),F(r[3],h,s)}function rr(r,t,n){var e;for(e=0;e<4;e++)T(r[e],t[e],n)}function tr(r,t){var n=v(),e=v(),o=v();Z(o,t[2]),F(n,t[0],o),F(e,t[1],o),z(r,e),r[31]^=P(n)<<7}function nr(r,t,n){var e,o;for(Y(r[0],s),Y(r[1],u),Y(r[2],u),Y(r[3],s),o=255;0<=o;--o)rr(r,t,e=n[o/8|0]>>(7&o)&1),$(t,r),$(r,r),rr(r,t,e)}function er(r,t){var n=[v(),v(),v(),v()];Y(n[0],e),Y(n[1],a),Y(n[2],u),F(n[3],e,a),nr(r,n,t)}function or(r,t,n){var e,o=new Uint8Array(64),i=[v(),v(),v(),v()];for(n||h(t,32),W(o,t,32),o[0]&=248,o[31]&=127,o[31]|=64,er(i,o),tr(r,i),e=0;e<32;e++)t[e+32]=r[e];return 0}var ir=new Float64Array([237,211,245,92,26,99,18,88,214,156,247,162,222,249,222,20,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,16]);function hr(r,t){var n,e,o,i;for(e=63;32<=e;--e){for(n=0,o=e-32,i=e-12;o<i;++o)t[o]+=n-16*t[e]*ir[o-(e-32)],n=Math.floor((t[o]+128)/256),t[o]-=256*n;t[o]+=n,t[e]=0}for(o=n=0;o<32;o++)t[o]+=n-(t[31]>>4)*ir[o],n=t[o]>>8,t[o]&=255;for(o=0;o<32;o++)t[o]-=n*ir[o];for(e=0;e<32;e++)t[e+1]+=t[e]>>8,r[e]=255&t[e]}function ar(r){var t,n=new Float64Array(64);for(t=0;t<64;t++)n[t]=r[t];for(t=0;t<64;t++)r[t]=0;hr(r,n)}function fr(r,t,n,e){var o,i,h=new Uint8Array(64),a=new Uint8Array(64),f=new Uint8Array(64),s=new Float64Array(64),u=[v(),v(),v(),v()];W(h,e,32),h[0]&=248,h[31]&=127,h[31]|=64;var c=n+64;for(o=0;o<n;o++)r[64+o]=t[o];for(o=0;o<32;o++)r[32+o]=h[32+o];for(W(f,r.subarray(32),n+32),ar(f),er(u,f),tr(r,u),o=32;o<64;o++)r[o]=e[o];for(W(a,r,n+64),ar(a),o=0;o<64;o++)s[o]=0;for(o=0;o<32;o++)s[o]=f[o];for(o=0;o<32;o++)for(i=0;i<32;i++)s[o+i]+=a[o]*h[i];return hr(r.subarray(32),s),c}function sr(r,t,n,e){var o,i=new Uint8Array(32),h=new Uint8Array(64),a=[v(),v(),v(),v()],f=[v(),v(),v(),v()];if(n<64)return-1;if(function(r,t){var n=v(),e=v(),o=v(),i=v(),h=v(),a=v(),f=v();if(Y(r[2],u),N(r[1],t),I(o,r[1]),F(i,o,c),C(o,o,r[2]),O(i,r[2],i),I(h,i),I(a,h),F(f,a,h),F(n,f,o),F(n,n,i),G(n,n),F(n,n,o),F(n,n,i),F(n,n,i),F(r[0],n,i),I(e,r[0]),F(e,e,i),R(e,o)&&F(r[0],r[0],l),I(e,r[0]),F(e,e,i),R(e,o))return 1;P(r[0])===t[31]>>7&&C(r[0],s,r[0]),F(r[3],r[0],r[1])}(f,e))return-1;for(o=0;o<n;o++)r[o]=t[o];for(o=0;o<32;o++)r[o+32]=e[o];if(W(h,r,n),ar(h),nr(a,f,h),er(f,t.subarray(32)),$(a,f),tr(i,a),n-=64,g(t,0,i,0)){for(o=0;o<n;o++)r[o]=0;return-1}for(o=0;o<n;o++)r[o]=t[o+64];return n}function ur(r,t){if(32!==r.length)throw new Error("bad key size");if(24!==t.length)throw new Error("bad nonce size")}function cr(){for(var r=0;r<arguments.length;r++)if(!(arguments[r]instanceof Uint8Array))throw new TypeError("unexpected type, use Uint8Array")}function yr(r){for(var t=0;t<r.length;t++)r[t]=0}i.lowlevel={crypto_core_hsalsa20:_,crypto_stream_xor:M,crypto_stream:x,crypto_stream_salsa20_xor:d,crypto_stream_salsa20:E,crypto_onetimeauth:B,crypto_onetimeauth_verify:S,crypto_verify_16:b,crypto_verify_32:g,crypto_secretbox:k,crypto_secretbox_open:K,crypto_scalarmult:q,crypto_scalarmult_base:D,crypto_box_beforenm:X,crypto_box_afternm:j,crypto_box:function(r,t,n,e,o,i){var h=new Uint8Array(32);return X(h,o,i),j(r,t,n,e,h)},crypto_box_open:function(r,t,n,e,o,i){var h=new Uint8Array(32);return X(h,o,i),H(r,t,n,e,h)},crypto_box_keypair:V,crypto_hash:W,crypto_sign:fr,crypto_sign_keypair:or,crypto_sign_open:sr,crypto_secretbox_KEYBYTES:32,crypto_secretbox_NONCEBYTES:24,crypto_secretbox_ZEROBYTES:32,crypto_secretbox_BOXZEROBYTES:16,crypto_scalarmult_BYTES:32,crypto_scalarmult_SCALARBYTES:32,crypto_box_PUBLICKEYBYTES:32,crypto_box_SECRETKEYBYTES:32,crypto_box_BEFORENMBYTES:32,crypto_box_NONCEBYTES:24,crypto_box_ZEROBYTES:32,crypto_box_BOXZEROBYTES:16,crypto_sign_BYTES:64,crypto_sign_PUBLICKEYBYTES:32,crypto_sign_SECRETKEYBYTES:64,crypto_sign_SEEDBYTES:32,crypto_hash_BYTES:64,gf:v,D:c,L:ir,pack25519:z,unpack25519:N,M:F,A:O,S:I,Z:C,pow2523:G,add:$,set25519:Y,modL:hr,scalarmult:nr,scalarbase:er},i.randomBytes=function(r){var t=new Uint8Array(r);return h(t,r),t},i.secretbox=function(r,t,n){cr(r,t,n),ur(n,t);for(var e=new Uint8Array(32+r.length),o=new Uint8Array(e.length),i=0;i<r.length;i++)e[i+32]=r[i];return k(o,e,e.length,t,n),o.subarray(16)},i.secretbox.open=function(r,t,n){cr(r,t,n),ur(n,t);for(var e=new Uint8Array(16+r.length),o=new Uint8Array(e.length),i=0;i<r.length;i++)e[i+16]=r[i];return e.length<32||0!==K(o,e,e.length,t,n)?null:o.subarray(32)},i.secretbox.keyLength=32,i.secretbox.nonceLength=24,i.secretbox.overheadLength=16,i.scalarMult=function(r,t){if(cr(r,t),32!==r.length)throw new Error("bad n size");if(32!==t.length)throw new Error("bad p size");var n=new Uint8Array(32);return q(n,r,t),n},i.scalarMult.base=function(r){if(cr(r),32!==r.length)throw new Error("bad n size");var t=new Uint8Array(32);return D(t,r),t},i.scalarMult.scalarLength=32,i.scalarMult.groupElementLength=32,i.box=function(r,t,n,e){var o=i.box.before(n,e);return i.secretbox(r,t,o)},i.box.before=function(r,t){cr(r,t),function(r,t){if(32!==r.length)throw new Error("bad public key size");if(32!==t.length)throw new Error("bad secret key size")}(r,t);var n=new Uint8Array(32);return X(n,r,t),n},i.box.after=i.secretbox,i.box.open=function(r,t,n,e){var o=i.box.before(n,e);return i.secretbox.open(r,t,o)},i.box.open.after=i.secretbox.open,i.box.keyPair=function(){var r=new Uint8Array(32),t=new Uint8Array(32);return V(r,t),{publicKey:r,secretKey:t}},i.box.keyPair.fromSecretKey=function(r){if(cr(r),32!==r.length)throw new Error("bad secret key size");var t=new Uint8Array(32);return D(t,r),{publicKey:t,secretKey:new Uint8Array(r)}},i.box.publicKeyLength=32,i.box.secretKeyLength=32,i.box.sharedKeyLength=32,i.box.nonceLength=24,i.box.overheadLength=i.secretbox.overheadLength,i.sign=function(r,t){if(cr(r,t),64!==t.length)throw new Error("bad secret key size");var n=new Uint8Array(64+r.length);return fr(n,r,r.length,t),n},i.sign.open=function(r,t){if(cr(r,t),32!==t.length)throw new Error("bad public key size");var n=new Uint8Array(r.length),e=sr(n,r,r.length,t);if(e<0)return null;for(var o=new Uint8Array(e),i=0;i<o.length;i++)o[i]=n[i];return o},i.sign.detached=function(r,t){for(var n=i.sign(r,t),e=new Uint8Array(64),o=0;o<e.length;o++)e[o]=n[o];return e},i.sign.detached.verify=function(r,t,n){if(cr(r,t,n),64!==t.length)throw new Error("bad signature size");if(32!==n.length)throw new Error("bad public key size");var e,o=new Uint8Array(64+r.length),i=new Uint8Array(64+r.length);for(e=0;e<64;e++)o[e]=t[e];for(e=0;e<r.length;e++)o[e+64]=r[e];return 0<=sr(i,o,o.length,n)},i.sign.keyPair=function(){var r=new Uint8Array(32),t=new Uint8Array(64);return or(r,t),{publicKey:r,secretKey:t}},i.sign.keyPair.fromSecretKey=function(r){if(cr(r),64!==r.length)throw new Error("bad secret key size");for(var t=new Uint8Array(32),n=0;n<t.length;n++)t[n]=r[32+n];return{publicKey:t,secretKey:new Uint8Array(r)}},i.sign.keyPair.fromSeed=function(r){if(cr(r),32!==r.length)throw new Error("bad seed size");for(var t=new Uint8Array(32),n=new Uint8Array(64),e=0;e<32;e++)n[e]=r[e];return or(t,n,!0),{publicKey:t,secretKey:n}},i.sign.publicKeyLength=32,i.sign.secretKeyLength=64,i.sign.seedLength=32,i.sign.signatureLength=64,i.hash=function(r){cr(r);var t=new Uint8Array(64);return W(t,r,r.length),t},i.hash.hashLength=64,i.verify=function(r,t){return cr(r,t),0!==r.length&&0!==t.length&&(r.length===t.length&&0===w(r,0,t,0,r.length))},i.setPRNG=function(r){h=r},function(){var o="undefined"!=typeof self?self.crypto||self.msCrypto:null;if(o&&o.getRandomValues){i.setPRNG(function(r,t){var n,e=new Uint8Array(t);for(n=0;n<t;n+=65536)o.getRandomValues(e.subarray(n,n+Math.min(t-n,65536)));for(n=0;n<t;n++)r[n]=e[n];yr(e)})}else"undefined"!=typeof require&&(o=require("crypto"))&&o.randomBytes&&i.setPRNG(function(r,t){var n,e=o.randomBytes(t);for(n=0;n<t;n++)r[n]=e[n];yr(e)})}()}("undefined"!=typeof module&&module.exports?module.exports:self.nacl=self.nacl||{});
pear-app/test-relay-push.mjs +98 -0
……@@ -0,0 +1,98 @@
1/**
2 * Test relay push with fixed ANProto signature
3 */
4import { WebSocket } from 'ws'
5import { ANProto } from './lib/anproto.js'
6import { YAMLHelper } from './lib/yaml-helper.js'
7
8// Your pubkey (get from: pear run . cli whoami)
9const PUBKEY = 'OEMqT42p1NhSH5cyOejXRIbQwT3TciFe5kMZ6gqBJIE='
10
11// Your keypair (132 chars) - get from Hyperbee metadata
12const KEYPAIR_STRING = 'OEMqT42p1NhSH5cyOejXRIbQwT3TciFe5kMZ6gqBJIE=5w5eLkSf2+vfZWjj3t4a+r8NhBq+yx9bHwZQtvElD10wQypPjanU2FIflzI56NdEhtDBPdNyIV7mQxnqCoEkgQ=='
13
14async function testPush() {
15 console.log('🧪 Testing relay push with fixed signature...\n')
16
17 // Load keypair
18 const keypair = ANProto.loadKeypair(KEYPAIR_STRING)
19 console.log('✓ Loaded keypair')
20 console.log(' Pubkey:', PUBKEY)
21
22 // Create test message
23 const metadata = {
24 name: 'TestUser',
25 image: null,
26 previous: null
27 }
28 const messageText = 'TEST RELAY PUSH - Signature Fix Test - ' + Date.now()
29 const yaml = YAMLHelper.create(metadata, messageText)
30 console.log('\n✓ Created YAML message:')
31 console.log(yaml.substring(0, 100) + '...')
32
33 // Hash and sign
34 const contentHash = ANProto.hash(yaml)
35 console.log('\n✓ Content hash:', contentHash.substring(0, 32) + '...')
36
37 const signature = ANProto.sign(contentHash, keypair)
38 console.log('✓ Signature length:', signature.length, 'chars')
39 console.log(' Signature:', signature.substring(0, 50) + '...')
40
41 // Connect to relay
42 console.log('\n📤 Connecting to wss://7rnx.net/relay/')
43 const ws = new WebSocket('wss://7rnx.net/relay/')
44
45 return new Promise((resolve, reject) => {
46 const timeout = setTimeout(() => {
47 ws.close()
48 reject(new Error('Connection timeout (10s)'))
49 }, 10000)
50
51 ws.on('open', () => {
52 clearTimeout(timeout)
53 console.log('✓ Connected!')
54
55 try {
56 // Send in order: pubkey, content, signature
57 ws.send(PUBKEY)
58 console.log(' → Sent pubkey')
59
60 ws.send(yaml)
61 console.log(' → Sent YAML content')
62
63 ws.send(signature)
64 console.log(' → Sent signature')
65
66 // Wait for relay to process
67 setTimeout(() => {
68 ws.close()
69 console.log('\n✅ Push complete!')
70 console.log('\nCheck relay: curl https://7rnx.net/relay/all | jq length')
71 resolve()
72 }, 2000)
73 } catch (err) {
74 clearTimeout(timeout)
75 ws.close()
76 reject(err)
77 }
78 })
79
80 ws.on('error', (err) => {
81 clearTimeout(timeout)
82 console.error('❌ WebSocket error:', err.message)
83 reject(err)
84 })
85
86 ws.on('message', (data) => {
87 console.log(' ← Received:', data.toString().substring(0, 50))
88 })
89 })
90}
91
92testPush().then(() => {
93 console.log('\n✅ Test successful!')
94 process.exit(0)
95}).catch(err => {
96 console.error('\n❌ Test failed:', err.message)
97 process.exit(1)
98})
pear-app/test-websocket.js +29 -0
……@@ -0,0 +1,29 @@
1// Simple WebSocket client to test if broadcasts are actually being sent
2const WebSocket = require('ws')
3
4const ws = new WebSocket('ws://localhost:3003')
5
6ws.on('open', () => {
7 console.log('✅ WebSocket connected to localhost:3003')
8 console.log('Waiting for messages...')
9})
10
11ws.on('message', (data) => {
12 console.log('📨 Received message:', data.toString())
13 try {
14 const parsed = JSON.parse(data.toString())
15 console.log('📦 Parsed:', JSON.stringify(parsed, null, 2))
16 } catch (err) {
17 console.log('⚠️ Could not parse as JSON')
18 }
19})
20
21ws.on('close', (code, reason) => {
22 console.log('❌ WebSocket closed:', code, reason.toString())
23})
24
25ws.on('error', (err) => {
26 console.error('🔥 WebSocket error:', err.message)
27})
28
29console.log('Connecting to ws://localhost:3003...')
pear-app/wirecore-storage-backup-1769011169/primary-key +1 -0
……@@ -0,0 +1 @@
1&+n]�0�{�~�R^p�>C��a�V��]�g