@freqhole/playlistz 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
// p2p sharing service for playlistz.
|
|
2
|
+
//
|
|
3
|
+
// covers the phase 5 surface:
|
|
4
|
+
// - endpoint settings (name, avatar, public/knock mode)
|
|
5
|
+
// - share link generation + the open-share-link flow
|
|
6
|
+
// - peer reconnect on boot (registerAndReconnectPeers pattern)
|
|
7
|
+
// - knock protocol requester + responder on the playlistz ALPN
|
|
8
|
+
//
|
|
9
|
+
// the responder also dispatches blob_request messages to the blob
|
|
10
|
+
// transfer service (phase 6) so a single stream handler covers the
|
|
11
|
+
// whole freqhole-playlistz/1 protocol.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
PLAYLISTZ_ALPN,
|
|
15
|
+
encodeShareToken,
|
|
16
|
+
decodeShareToken,
|
|
17
|
+
shareFragment,
|
|
18
|
+
sendMessage,
|
|
19
|
+
readMessage,
|
|
20
|
+
addPeer as addPeerToDoc,
|
|
21
|
+
type Message,
|
|
22
|
+
type BiStreamLike,
|
|
23
|
+
type SharePayloadV1,
|
|
24
|
+
} from "@freqhole/api-client/playlistz";
|
|
25
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo";
|
|
26
|
+
import { getIrohAdapter, findPlaylistDoc, flushDoc, authorizePeerForDoc } from "./automergeRepo.js";
|
|
27
|
+
import {
|
|
28
|
+
startP2P,
|
|
29
|
+
getIdentity,
|
|
30
|
+
getNode,
|
|
31
|
+
waitForNode,
|
|
32
|
+
onLeadershipChange,
|
|
33
|
+
hasExistingIdentity,
|
|
34
|
+
} from "./p2pService.js";
|
|
35
|
+
import {
|
|
36
|
+
addDocIndexEntry,
|
|
37
|
+
getDocIndexEntry,
|
|
38
|
+
getAllDocIndexEntries,
|
|
39
|
+
upsertKnock,
|
|
40
|
+
getAllKnocks,
|
|
41
|
+
upsertAccessGrant,
|
|
42
|
+
getAccessGrant,
|
|
43
|
+
} from "./docIndexService.js";
|
|
44
|
+
import { loadSetting, saveSetting } from "./indexedDBService.js";
|
|
45
|
+
import type { KnockRecord } from "./indexedDBService.js";
|
|
46
|
+
import { serveBlobRequest } from "./blobTransferService.js";
|
|
47
|
+
import { log } from "../utils/log.js";
|
|
48
|
+
|
|
49
|
+
// --- endpoint settings ---
|
|
50
|
+
|
|
51
|
+
export interface ShareSettings {
|
|
52
|
+
name: string;
|
|
53
|
+
mode: "public" | "knock";
|
|
54
|
+
avatarDataUrl?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SETTINGS_KEY = "p2p:endpoint";
|
|
58
|
+
|
|
59
|
+
export async function getShareSettings(): Promise<ShareSettings> {
|
|
60
|
+
const stored = await loadSetting<ShareSettings>(SETTINGS_KEY);
|
|
61
|
+
return stored ?? { name: "", mode: "knock" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function saveShareSettings(
|
|
65
|
+
settings: ShareSettings
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
await saveSetting(SETTINGS_KEY, settings);
|
|
68
|
+
// fire-and-forget: tell connected peers about our updated identity
|
|
69
|
+
void notifyPeersOfIdentityUpdate(settings);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* open a stream to every currently-connected peer and send our current
|
|
74
|
+
* name + avatar so they can update their docIndex entries without waiting
|
|
75
|
+
* for the next explicit hello exchange.
|
|
76
|
+
*/
|
|
77
|
+
async function notifyPeersOfIdentityUpdate(
|
|
78
|
+
settings: ShareSettings
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
if (!protocolHandlerRegistered) return;
|
|
81
|
+
let adapter: ReturnType<typeof getIrohAdapter>;
|
|
82
|
+
try {
|
|
83
|
+
adapter = getIrohAdapter();
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const entries = await getAllDocIndexEntries().catch(() => [] as Awaited<ReturnType<typeof getAllDocIndexEntries>>);
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
const myNodeId = getIdentity()?.node_id ?? "";
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const nodeId = entry.remoteNodeId;
|
|
92
|
+
if (!nodeId || nodeId === myNodeId || seen.has(nodeId)) continue;
|
|
93
|
+
seen.add(nodeId);
|
|
94
|
+
if (!adapter.isConnected(nodeId)) continue;
|
|
95
|
+
void (async () => {
|
|
96
|
+
try {
|
|
97
|
+
const stream = await openPlaylistzStream(nodeId);
|
|
98
|
+
try {
|
|
99
|
+
await sendMessage(stream, {
|
|
100
|
+
v: 1,
|
|
101
|
+
type: "identity_update",
|
|
102
|
+
...(settings.name ? { name: settings.name } : {}),
|
|
103
|
+
...(settings.avatarDataUrl
|
|
104
|
+
? { avatarDataUrl: settings.avatarDataUrl }
|
|
105
|
+
: {}),
|
|
106
|
+
});
|
|
107
|
+
} finally {
|
|
108
|
+
stream.close();
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// peer unreachable - they'll get fresh data on next hello
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- p2p bootstrap for sharing ---
|
|
118
|
+
|
|
119
|
+
let protocolHandlerRegistered = false;
|
|
120
|
+
let reconnectDone = false;
|
|
121
|
+
let leadershipWatched = false;
|
|
122
|
+
// interval id for the periodic reconnect timer (cleared on reset)
|
|
123
|
+
let reconnectIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
124
|
+
|
|
125
|
+
// listeners notified when the knock inbox changes (new knock arrived)
|
|
126
|
+
const knockListeners = new Set<() => void>();
|
|
127
|
+
|
|
128
|
+
export function onKnocksChanged(cb: () => void): () => void {
|
|
129
|
+
knockListeners.add(cb);
|
|
130
|
+
return () => {
|
|
131
|
+
knockListeners.delete(cb);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function notifyKnocksChanged(): void {
|
|
136
|
+
for (const cb of knockListeners) {
|
|
137
|
+
try {
|
|
138
|
+
cb();
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore listener errors
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* start p2p and wire up the playlistz protocol responder + peer reconnect.
|
|
147
|
+
* idempotent. safe to call from UI event handlers.
|
|
148
|
+
*/
|
|
149
|
+
export async function ensureSharingReady(): Promise<void> {
|
|
150
|
+
await startP2P();
|
|
151
|
+
|
|
152
|
+
if (!protocolHandlerRegistered) {
|
|
153
|
+
protocolHandlerRegistered = true;
|
|
154
|
+
const adapter = getIrohAdapter();
|
|
155
|
+
adapter.registerAlpnHandler(PLAYLISTZ_ALPN, (stream) => {
|
|
156
|
+
void handlePlaylistzStream(stream);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// reconnect to peers recorded in docs once we hold the node
|
|
161
|
+
if (!leadershipWatched) {
|
|
162
|
+
leadershipWatched = true;
|
|
163
|
+
onLeadershipChange((leader) => {
|
|
164
|
+
if (leader && !reconnectDone) {
|
|
165
|
+
reconnectDone = true;
|
|
166
|
+
void reconnectKnownPeers();
|
|
167
|
+
// periodic reconnect: re-dial known peers every 90s so automerge
|
|
168
|
+
// can sync changes that arrived while the connection was down
|
|
169
|
+
if (!reconnectIntervalId) {
|
|
170
|
+
reconnectIntervalId = setInterval(() => void reconnectKnownPeers(), 90_000);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// startP2P resolves before the midden node finishes booting - wait so
|
|
177
|
+
// callers (buildShareLink, openShareLink) can dial immediately. resolves
|
|
178
|
+
// null fast in non-leader tabs, where the node lives elsewhere.
|
|
179
|
+
await waitForNode();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* resume p2p on app boot, but only if the user has already enabled it
|
|
184
|
+
* (an identity exists). first-time p2p start stays an explicit user action
|
|
185
|
+
* in the share panel.
|
|
186
|
+
*/
|
|
187
|
+
export async function resumeSharingIfEnabled(): Promise<void> {
|
|
188
|
+
if (await hasExistingIdentity()) {
|
|
189
|
+
await ensureSharingReady();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* do a quick hello exchange with a known peer and refresh their name +
|
|
195
|
+
* avatar in docIndex entries and access grant record. silently ignores
|
|
196
|
+
* errors (peer may be offline).
|
|
197
|
+
*/
|
|
198
|
+
async function refreshPeerIdentity(nodeId: string): Promise<void> {
|
|
199
|
+
const identity = getIdentity();
|
|
200
|
+
const settings = await getShareSettings().catch(
|
|
201
|
+
() => ({ name: "", mode: "knock" as const })
|
|
202
|
+
);
|
|
203
|
+
let peerName: string | undefined;
|
|
204
|
+
let peerAvatarDataUrl: string | undefined;
|
|
205
|
+
try {
|
|
206
|
+
const stream = await openPlaylistzStream(nodeId);
|
|
207
|
+
try {
|
|
208
|
+
await sendMessage(stream, {
|
|
209
|
+
v: 1,
|
|
210
|
+
type: "hello",
|
|
211
|
+
nodeId: identity?.node_id ?? "",
|
|
212
|
+
...(settings.name ? { name: settings.name } : {}),
|
|
213
|
+
});
|
|
214
|
+
const reply = await readMessage(stream);
|
|
215
|
+
if (reply?.type === "hello_ok") {
|
|
216
|
+
peerName = reply.name;
|
|
217
|
+
peerAvatarDataUrl = reply.avatarDataUrl;
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
stream.close();
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
return; // peer offline or unreachable
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!peerName && !peerAvatarDataUrl) return;
|
|
227
|
+
|
|
228
|
+
// update all docIndex entries that reference this peer
|
|
229
|
+
const entries = await getAllDocIndexEntries().catch(
|
|
230
|
+
() => [] as Awaited<ReturnType<typeof getAllDocIndexEntries>>
|
|
231
|
+
);
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (entry.remoteNodeId !== nodeId) continue;
|
|
234
|
+
await addDocIndexEntry({
|
|
235
|
+
...entry,
|
|
236
|
+
...(peerName ? { remoteName: peerName } : {}),
|
|
237
|
+
...(peerAvatarDataUrl ? { remoteAvatarDataUrl: peerAvatarDataUrl } : {}),
|
|
238
|
+
}).catch(() => {});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// update access grant if we have one for this peer
|
|
242
|
+
const grant = await getAccessGrant(nodeId).catch(() => undefined);
|
|
243
|
+
if (grant) {
|
|
244
|
+
await upsertAccessGrant({
|
|
245
|
+
...grant,
|
|
246
|
+
...(peerName ? { name: peerName } : {}),
|
|
247
|
+
...(peerAvatarDataUrl ? { avatarDataUrl: peerAvatarDataUrl } : {}),
|
|
248
|
+
}).catch(() => {});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* connect to every peer recorded in the peers map of any indexed doc.
|
|
254
|
+
* also warms the repo's docPeerCache so sharePolicy can announce docs.
|
|
255
|
+
* pre-seeds the cache from docIndex entries before doc handles resolve
|
|
256
|
+
* to close the timing window where a peer reconnects before the cache
|
|
257
|
+
* is populated from the doc.
|
|
258
|
+
*/
|
|
259
|
+
export async function reconnectKnownPeers(): Promise<void> {
|
|
260
|
+
const identity = getIdentity();
|
|
261
|
+
const myNodeId = identity?.node_id ?? "";
|
|
262
|
+
const adapter = getIrohAdapter();
|
|
263
|
+
const entries = await getAllDocIndexEntries();
|
|
264
|
+
const seen = new Set<string>();
|
|
265
|
+
|
|
266
|
+
// fast pass: pre-authorize known remote peers from the docIndex before
|
|
267
|
+
// waiting on doc handles. this prevents sharePolicy from rejecting a
|
|
268
|
+
// reconnecting peer during the async doc-load window.
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
if (entry.remoteNodeId && entry.remoteNodeId !== myNodeId) {
|
|
271
|
+
authorizePeerForDoc(entry.docId as AutomergeUrl, entry.remoteNodeId);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const entry of entries) {
|
|
276
|
+
try {
|
|
277
|
+
const handle = await findPlaylistDoc(entry.docId as AutomergeUrl);
|
|
278
|
+
const doc = handle.doc();
|
|
279
|
+
if (!doc) continue;
|
|
280
|
+
for (const nodeId of Object.keys(doc.peers ?? {})) {
|
|
281
|
+
if (nodeId && nodeId !== myNodeId && !seen.has(nodeId)) {
|
|
282
|
+
seen.add(nodeId);
|
|
283
|
+
void adapter.addPeer(nodeId).then(async () => {
|
|
284
|
+
// refresh the peer's identity in docIndex + grant after connecting
|
|
285
|
+
void refreshPeerIdentity(nodeId);
|
|
286
|
+
}).catch((err) => {
|
|
287
|
+
log.warn("p2p.reconnect", "reconnect to peer failed:", nodeId.slice(0, 16), err);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// doc unavailable locally - skip
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- share links ---
|
|
298
|
+
|
|
299
|
+
// discriminated result of opening a share link.
|
|
300
|
+
// "synced" - doc is now local (direct access or already present)
|
|
301
|
+
// "knock_required" - owner is in knock mode; call knockForDocAccess to proceed
|
|
302
|
+
export type OpenShareLinkResult =
|
|
303
|
+
| { status: "synced"; docId: string }
|
|
304
|
+
| {
|
|
305
|
+
status: "knock_required";
|
|
306
|
+
ownerNodeId: string;
|
|
307
|
+
ownerName?: string;
|
|
308
|
+
docId: string;
|
|
309
|
+
title?: string;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* build a share link for a playlist doc. requires a running node (the
|
|
314
|
+
* link embeds our node id so the recipient can dial us). embeds the
|
|
315
|
+
* current sharing mode so recipients know if a knock is required.
|
|
316
|
+
*/
|
|
317
|
+
export async function buildShareLink(
|
|
318
|
+
docId: string,
|
|
319
|
+
title?: string
|
|
320
|
+
): Promise<{ token: string; url: string; fragment: string }> {
|
|
321
|
+
await ensureSharingReady();
|
|
322
|
+
const identity = getIdentity();
|
|
323
|
+
if (!identity?.node_id) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
"p2p node is not running - cannot create a share link without a node id"
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const settings = await getShareSettings();
|
|
329
|
+
const payload: SharePayloadV1 = {
|
|
330
|
+
v: 1,
|
|
331
|
+
n: identity.node_id,
|
|
332
|
+
d: docId,
|
|
333
|
+
...(title ? { t: title } : {}),
|
|
334
|
+
...(settings.mode === "knock" ? { m: "knock" } : {}),
|
|
335
|
+
};
|
|
336
|
+
const token = encodeShareToken(payload);
|
|
337
|
+
const fragment = shareFragment(payload);
|
|
338
|
+
const base = `${window.location.origin}${window.location.pathname}`;
|
|
339
|
+
return { token, url: `${base}${fragment}`, fragment };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* perform the actual automerge doc sync for a share payload.
|
|
344
|
+
* dials the peer, finds the doc, records peers in the doc, and indexes it.
|
|
345
|
+
* does a quick hello exchange to capture the peer's name and avatar.
|
|
346
|
+
*/
|
|
347
|
+
async function syncSharedDoc(
|
|
348
|
+
payload: SharePayloadV1
|
|
349
|
+
): Promise<{ status: "synced"; docId: string }> {
|
|
350
|
+
const identity = getIdentity();
|
|
351
|
+
const adapter = getIrohAdapter();
|
|
352
|
+
const mySettings = await getShareSettings();
|
|
353
|
+
|
|
354
|
+
// pre-authorize the sharing peer so sharePolicy trusts them before the doc
|
|
355
|
+
// arrives (the doc can't arrive if the policy already rejects the peer)
|
|
356
|
+
authorizePeerForDoc(payload.d as AutomergeUrl, payload.n);
|
|
357
|
+
|
|
358
|
+
// fetch name + avatar from the sharer via a hello exchange.
|
|
359
|
+
// best-effort: failures are silently ignored so the main sync still proceeds.
|
|
360
|
+
let peerName: string | undefined;
|
|
361
|
+
let peerAvatarDataUrl: string | undefined;
|
|
362
|
+
try {
|
|
363
|
+
const stream = await openPlaylistzStream(payload.n);
|
|
364
|
+
try {
|
|
365
|
+
await sendMessage(stream, {
|
|
366
|
+
v: 1,
|
|
367
|
+
type: "hello",
|
|
368
|
+
nodeId: identity?.node_id ?? "",
|
|
369
|
+
...(mySettings.name ? { name: mySettings.name } : {}),
|
|
370
|
+
});
|
|
371
|
+
const reply = await readMessage(stream);
|
|
372
|
+
if (reply?.type === "hello_ok") {
|
|
373
|
+
peerName = reply.name;
|
|
374
|
+
peerAvatarDataUrl = reply.avatarDataUrl;
|
|
375
|
+
}
|
|
376
|
+
} finally {
|
|
377
|
+
stream.close();
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
// peer may be offline or reject hello - not fatal
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const alreadyLocal = await getDocIndexEntry(payload.d).catch(() => null);
|
|
384
|
+
if (!alreadyLocal) {
|
|
385
|
+
for (let attempt = 0; ; attempt++) {
|
|
386
|
+
try {
|
|
387
|
+
await adapter.addPeer(payload.n);
|
|
388
|
+
break;
|
|
389
|
+
} catch (err) {
|
|
390
|
+
if (attempt >= 5) {
|
|
391
|
+
log.warn("p2p.connect", "could not connect to sharing peer:", err);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const handle = await findPlaylistDoc(payload.d as AutomergeUrl);
|
|
400
|
+
const doc = handle.doc();
|
|
401
|
+
|
|
402
|
+
const myNodeId = identity?.node_id;
|
|
403
|
+
if (doc) {
|
|
404
|
+
const peers = doc.peers ?? {};
|
|
405
|
+
const missingSelf = !!myNodeId && !(myNodeId in peers);
|
|
406
|
+
const missingSharer = !(payload.n in peers);
|
|
407
|
+
if (missingSelf || missingSharer) {
|
|
408
|
+
handle.change((d) => {
|
|
409
|
+
if (missingSelf && myNodeId) addPeerToDoc(d, myNodeId);
|
|
410
|
+
if (missingSharer) addPeerToDoc(d, payload.n);
|
|
411
|
+
});
|
|
412
|
+
await flushDoc(payload.d as AutomergeUrl);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const existing = await getDocIndexEntry(payload.d);
|
|
417
|
+
if (!existing) {
|
|
418
|
+
await addDocIndexEntry({
|
|
419
|
+
docId: payload.d,
|
|
420
|
+
title: doc?.title || payload.t || "shared playlist",
|
|
421
|
+
addedAt: Date.now(),
|
|
422
|
+
source: "shared",
|
|
423
|
+
remoteNodeId: payload.n,
|
|
424
|
+
remoteName: peerName,
|
|
425
|
+
remoteAvatarDataUrl: peerAvatarDataUrl,
|
|
426
|
+
});
|
|
427
|
+
} else if (peerName || peerAvatarDataUrl) {
|
|
428
|
+
// update name/avatar if we got fresher data
|
|
429
|
+
await addDocIndexEntry({
|
|
430
|
+
...existing,
|
|
431
|
+
...(peerName ? { remoteName: peerName } : {}),
|
|
432
|
+
...(peerAvatarDataUrl ? { remoteAvatarDataUrl: peerAvatarDataUrl } : {}),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { status: "synced", docId: payload.d };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* open a share link (or raw token).
|
|
441
|
+
* - if the link embeds `m: "knock"`, returns knock_required without syncing.
|
|
442
|
+
* call knockForDocAccess() once the user confirms, then the doc syncs.
|
|
443
|
+
* - otherwise syncs the doc immediately and returns { status: "synced" }.
|
|
444
|
+
*/
|
|
445
|
+
export async function openShareLink(
|
|
446
|
+
input: string
|
|
447
|
+
): Promise<OpenShareLinkResult> {
|
|
448
|
+
const payload = decodeShareToken(input);
|
|
449
|
+
if (!payload) {
|
|
450
|
+
throw new Error("invalid share link");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
await ensureSharingReady();
|
|
454
|
+
|
|
455
|
+
// if already local, skip re-sync
|
|
456
|
+
const alreadyLocal = await getDocIndexEntry(payload.d).catch(() => null);
|
|
457
|
+
if (alreadyLocal) {
|
|
458
|
+
return { status: "synced", docId: payload.d };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// knock mode encoded in the link: gate sync behind a knock
|
|
462
|
+
if (payload.m === "knock") {
|
|
463
|
+
return {
|
|
464
|
+
status: "knock_required",
|
|
465
|
+
ownerNodeId: payload.n,
|
|
466
|
+
docId: payload.d,
|
|
467
|
+
title: payload.t,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return syncSharedDoc(payload);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* check location.hash for a #share/ fragment. if present, open it and
|
|
476
|
+
* clear the fragment. returns an OpenShareLinkResult or null.
|
|
477
|
+
*/
|
|
478
|
+
export async function handleShareFragment(): Promise<OpenShareLinkResult | null> {
|
|
479
|
+
const hash = window.location.hash;
|
|
480
|
+
if (!hash.startsWith("#share/")) return null;
|
|
481
|
+
try {
|
|
482
|
+
const result = await openShareLink(hash);
|
|
483
|
+
// clear the fragment so reloads don't re-trigger
|
|
484
|
+
history.replaceState(null, "", window.location.pathname);
|
|
485
|
+
return result;
|
|
486
|
+
} catch (err) {
|
|
487
|
+
log.error("share.fragment", "failed to open share link:", err);
|
|
488
|
+
history.replaceState(null, "", window.location.pathname);
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --- knock requester ---
|
|
494
|
+
|
|
495
|
+
export interface PeerPlaylistListing {
|
|
496
|
+
nodeId: string;
|
|
497
|
+
name?: string;
|
|
498
|
+
avatarDataUrl?: string;
|
|
499
|
+
public: boolean;
|
|
500
|
+
items: { docId: string; title: string; songCount: number }[];
|
|
501
|
+
knockRequired: boolean;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function openPlaylistzStream(nodeId: string): Promise<BiStreamLike> {
|
|
505
|
+
await ensureSharingReady();
|
|
506
|
+
const node = getNode();
|
|
507
|
+
if (!node) {
|
|
508
|
+
throw new Error("p2p node is not running in this tab");
|
|
509
|
+
}
|
|
510
|
+
return (await node.open_bi(
|
|
511
|
+
nodeId,
|
|
512
|
+
PLAYLISTZ_ALPN
|
|
513
|
+
)) as unknown as BiStreamLike;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* query a peer for its playlist listing. sends hello + list_playlists.
|
|
518
|
+
* if the peer requires a knock, knockRequired is true and items is empty.
|
|
519
|
+
*/
|
|
520
|
+
export async function queryPeerPlaylists(
|
|
521
|
+
nodeId: string
|
|
522
|
+
): Promise<PeerPlaylistListing> {
|
|
523
|
+
const identity = getIdentity();
|
|
524
|
+
const settings = await getShareSettings();
|
|
525
|
+
const stream = await openPlaylistzStream(nodeId);
|
|
526
|
+
try {
|
|
527
|
+
await sendMessage(stream, {
|
|
528
|
+
v: 1,
|
|
529
|
+
type: "hello",
|
|
530
|
+
nodeId: identity?.node_id ?? "",
|
|
531
|
+
...(settings.name ? { name: settings.name } : {}),
|
|
532
|
+
});
|
|
533
|
+
const helloReply = await readMessage(stream);
|
|
534
|
+
if (helloReply?.type !== "hello_ok") {
|
|
535
|
+
throw new Error("peer did not answer hello");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
await sendMessage(stream, { v: 1, type: "list_playlists" });
|
|
539
|
+
const listReply = await readMessage(stream);
|
|
540
|
+
|
|
541
|
+
if (listReply?.type === "playlists") {
|
|
542
|
+
return {
|
|
543
|
+
nodeId,
|
|
544
|
+
name: helloReply.name,
|
|
545
|
+
avatarDataUrl: helloReply.avatarDataUrl,
|
|
546
|
+
public: helloReply.public,
|
|
547
|
+
items: listReply.items,
|
|
548
|
+
knockRequired: false,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (listReply?.type === "error" && listReply.code === "knock_required") {
|
|
552
|
+
return {
|
|
553
|
+
nodeId,
|
|
554
|
+
name: helloReply.name,
|
|
555
|
+
avatarDataUrl: helloReply.avatarDataUrl,
|
|
556
|
+
public: helloReply.public,
|
|
557
|
+
items: [],
|
|
558
|
+
knockRequired: true,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
throw new Error("unexpected reply to list_playlists");
|
|
562
|
+
} finally {
|
|
563
|
+
stream.close();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* knock on a peer. returns the resulting status; when accepted, the
|
|
569
|
+
* granted doc ids are opened + indexed automatically.
|
|
570
|
+
*/
|
|
571
|
+
export async function knockOnPeer(
|
|
572
|
+
nodeId: string,
|
|
573
|
+
message?: string
|
|
574
|
+
): Promise<{ status: "pending" | "accepted" | "denied"; docIds: string[] }> {
|
|
575
|
+
const identity = getIdentity();
|
|
576
|
+
const settings = await getShareSettings();
|
|
577
|
+
const stream = await openPlaylistzStream(nodeId);
|
|
578
|
+
let reply: Message | null;
|
|
579
|
+
try {
|
|
580
|
+
await sendMessage(stream, {
|
|
581
|
+
v: 1,
|
|
582
|
+
type: "knock",
|
|
583
|
+
nodeId: identity?.node_id ?? "",
|
|
584
|
+
...(settings.name ? { name: settings.name } : {}),
|
|
585
|
+
...(message ? { message } : {}),
|
|
586
|
+
});
|
|
587
|
+
reply = await readMessage(stream);
|
|
588
|
+
} finally {
|
|
589
|
+
stream.close();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (reply?.type !== "knock_status") {
|
|
593
|
+
throw new Error("peer did not answer knock");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// track the outbound knock for the UI
|
|
597
|
+
await upsertKnock({
|
|
598
|
+
id: `out:${nodeId}`,
|
|
599
|
+
nodeId,
|
|
600
|
+
direction: "outbound",
|
|
601
|
+
name: "",
|
|
602
|
+
message: message ?? "",
|
|
603
|
+
status: reply.status === "denied" ? "rejected" : reply.status,
|
|
604
|
+
createdAt: Date.now(),
|
|
605
|
+
...(reply.status !== "pending" ? { processedAt: Date.now() } : {}),
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const docIds = reply.grantedDocIds ?? [];
|
|
609
|
+
if (reply.status === "accepted" && docIds.length > 0) {
|
|
610
|
+
const adapter = getIrohAdapter();
|
|
611
|
+
await adapter.addPeer(nodeId).catch(() => {});
|
|
612
|
+
for (const docId of docIds) {
|
|
613
|
+
try {
|
|
614
|
+
const handle = await findPlaylistDoc(docId as AutomergeUrl);
|
|
615
|
+
const doc = handle.doc();
|
|
616
|
+
const myNodeId = identity?.node_id;
|
|
617
|
+
if (myNodeId && doc && !(myNodeId in (doc.peers ?? {}))) {
|
|
618
|
+
handle.change((d) => addPeerToDoc(d, myNodeId));
|
|
619
|
+
await flushDoc(docId as AutomergeUrl);
|
|
620
|
+
}
|
|
621
|
+
if (!(await getDocIndexEntry(docId))) {
|
|
622
|
+
await addDocIndexEntry({
|
|
623
|
+
docId,
|
|
624
|
+
title: doc?.title || "shared playlist",
|
|
625
|
+
addedAt: Date.now(),
|
|
626
|
+
source: "shared",
|
|
627
|
+
remoteNodeId: nodeId,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
} catch (err) {
|
|
631
|
+
log.warn("p2p.knock", "failed to open granted doc:", docId, err);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return { status: reply.status, docIds };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* send a doc_access knock to a specific peer for a specific playlist doc.
|
|
641
|
+
* used after openShareLink returns knock_required.
|
|
642
|
+
* when accepted, syncs the doc and indexes it automatically.
|
|
643
|
+
*/
|
|
644
|
+
export async function knockForDocAccess(
|
|
645
|
+
ownerNodeId: string,
|
|
646
|
+
docId: string,
|
|
647
|
+
message: string,
|
|
648
|
+
titleHint?: string
|
|
649
|
+
): Promise<{ status: "pending" | "accepted" | "denied" }> {
|
|
650
|
+
const identity = getIdentity();
|
|
651
|
+
const settings = await getShareSettings();
|
|
652
|
+
const stream = await openPlaylistzStream(ownerNodeId);
|
|
653
|
+
let reply: Message | null;
|
|
654
|
+
try {
|
|
655
|
+
await sendMessage(stream, {
|
|
656
|
+
v: 1,
|
|
657
|
+
type: "knock",
|
|
658
|
+
nodeId: identity?.node_id ?? "",
|
|
659
|
+
...(settings.name ? { name: settings.name } : {}),
|
|
660
|
+
...(message ? { message } : {}),
|
|
661
|
+
knockType: "doc_access",
|
|
662
|
+
docId,
|
|
663
|
+
});
|
|
664
|
+
reply = await readMessage(stream);
|
|
665
|
+
} finally {
|
|
666
|
+
stream.close();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (reply?.type !== "knock_status") {
|
|
670
|
+
throw new Error("peer did not answer knock");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
await upsertKnock({
|
|
674
|
+
id: `out:${ownerNodeId}:doc:${docId}`,
|
|
675
|
+
nodeId: ownerNodeId,
|
|
676
|
+
direction: "outbound",
|
|
677
|
+
name: "",
|
|
678
|
+
message,
|
|
679
|
+
status: reply.status === "denied" ? "rejected" : reply.status,
|
|
680
|
+
createdAt: Date.now(),
|
|
681
|
+
knockType: "doc_access",
|
|
682
|
+
requestedDocId: docId,
|
|
683
|
+
...(reply.status !== "pending" ? { processedAt: Date.now() } : {}),
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
if (reply.status === "accepted") {
|
|
687
|
+
const granted = reply.grantedDocIds ?? [docId];
|
|
688
|
+
if (granted.includes(docId)) {
|
|
689
|
+
await syncSharedDoc({ v: 1, n: ownerNodeId, d: docId, ...(titleHint ? { t: titleHint } : {}) });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return { status: reply.status };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* accept an inbound knock: persist the grant, record the peer in each
|
|
698
|
+
* granted doc, and dial the peer so sync starts immediately.
|
|
699
|
+
*/
|
|
700
|
+
export async function acceptKnock(
|
|
701
|
+
knockId: string,
|
|
702
|
+
docIds: string[]
|
|
703
|
+
): Promise<void> {
|
|
704
|
+
const knocks = await getAllKnocks();
|
|
705
|
+
const knock = knocks.find((k) => k.id === knockId);
|
|
706
|
+
if (!knock) throw new Error("knock not found");
|
|
707
|
+
|
|
708
|
+
// try to get the peer's avatar from any docIndex entry we already have
|
|
709
|
+
const allEntries = await getAllDocIndexEntries().catch(() => [] as Awaited<ReturnType<typeof getAllDocIndexEntries>>);
|
|
710
|
+
const peerEntry = allEntries.find((e) => e.remoteNodeId === knock.nodeId);
|
|
711
|
+
|
|
712
|
+
await upsertAccessGrant({
|
|
713
|
+
nodeId: knock.nodeId,
|
|
714
|
+
name: knock.name,
|
|
715
|
+
grantedAt: Date.now(),
|
|
716
|
+
docIds,
|
|
717
|
+
...(peerEntry?.remoteAvatarDataUrl
|
|
718
|
+
? { avatarDataUrl: peerEntry.remoteAvatarDataUrl }
|
|
719
|
+
: {}),
|
|
720
|
+
});
|
|
721
|
+
await upsertKnock({
|
|
722
|
+
...knock,
|
|
723
|
+
status: "accepted",
|
|
724
|
+
processedAt: Date.now(),
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
for (const docId of docIds) {
|
|
728
|
+
try {
|
|
729
|
+
const handle = await findPlaylistDoc(docId as AutomergeUrl);
|
|
730
|
+
const doc = handle.doc();
|
|
731
|
+
if (doc && !(knock.nodeId in (doc.peers ?? {}))) {
|
|
732
|
+
handle.change((d) => addPeerToDoc(d, knock.nodeId));
|
|
733
|
+
await flushDoc(docId as AutomergeUrl);
|
|
734
|
+
}
|
|
735
|
+
} catch (err) {
|
|
736
|
+
log.warn("p2p.knock", "failed to record peer in doc:", docId, err);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const adapter = getIrohAdapter();
|
|
741
|
+
await adapter.addPeer(knock.nodeId).catch(() => {});
|
|
742
|
+
|
|
743
|
+
// fire-and-forget: notify the peer they've been accepted so they don't
|
|
744
|
+
// have to poll. if the peer is offline this fails silently.
|
|
745
|
+
const identity = getIdentity();
|
|
746
|
+
void (async () => {
|
|
747
|
+
try {
|
|
748
|
+
const stream = await openPlaylistzStream(knock.nodeId);
|
|
749
|
+
try {
|
|
750
|
+
await sendMessage(stream, {
|
|
751
|
+
v: 1,
|
|
752
|
+
type: "knock_notify",
|
|
753
|
+
status: "accepted",
|
|
754
|
+
docIds,
|
|
755
|
+
ownerNodeId: identity?.node_id ?? "",
|
|
756
|
+
});
|
|
757
|
+
} finally {
|
|
758
|
+
stream.close();
|
|
759
|
+
}
|
|
760
|
+
} catch {
|
|
761
|
+
// peer offline or unreachable - they'll get the status on their next knock
|
|
762
|
+
}
|
|
763
|
+
})();
|
|
764
|
+
|
|
765
|
+
notifyKnocksChanged();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** deny an inbound knock. */
|
|
769
|
+
export async function denyKnock(knockId: string): Promise<void> {
|
|
770
|
+
const knocks = await getAllKnocks();
|
|
771
|
+
const knock = knocks.find((k) => k.id === knockId);
|
|
772
|
+
if (!knock) return;
|
|
773
|
+
await upsertKnock({
|
|
774
|
+
...knock,
|
|
775
|
+
status: "rejected",
|
|
776
|
+
processedAt: Date.now(),
|
|
777
|
+
});
|
|
778
|
+
notifyKnocksChanged();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** list inbound knocks for the inbox UI (newest first). */
|
|
782
|
+
export async function getInboundKnocks(): Promise<KnockRecord[]> {
|
|
783
|
+
const knocks = await getAllKnocks();
|
|
784
|
+
return knocks
|
|
785
|
+
.filter((k) => k.direction === "inbound")
|
|
786
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** list outbound knocks (sent by us) for the pending-access UI. */
|
|
790
|
+
export async function getOutboundKnocks(): Promise<KnockRecord[]> {
|
|
791
|
+
const knocks = await getAllKnocks();
|
|
792
|
+
return knocks
|
|
793
|
+
.filter((k) => k.direction === "outbound")
|
|
794
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// --- protocol responder ---
|
|
798
|
+
|
|
799
|
+
async function buildPlaylistItems(): Promise<
|
|
800
|
+
{ docId: string; title: string; songCount: number }[]
|
|
801
|
+
> {
|
|
802
|
+
const entries = await getAllDocIndexEntries();
|
|
803
|
+
const items: { docId: string; title: string; songCount: number }[] = [];
|
|
804
|
+
for (const entry of entries) {
|
|
805
|
+
try {
|
|
806
|
+
const handle = await findPlaylistDoc(entry.docId as AutomergeUrl);
|
|
807
|
+
const doc = handle.doc();
|
|
808
|
+
items.push({
|
|
809
|
+
docId: entry.docId,
|
|
810
|
+
title: doc?.title || entry.title,
|
|
811
|
+
songCount: doc ? Object.keys(doc.songs ?? {}).length : 0,
|
|
812
|
+
});
|
|
813
|
+
} catch {
|
|
814
|
+
items.push({ docId: entry.docId, title: entry.title, songCount: 0 });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return items;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* handle one inbound stream on the playlistz ALPN. loops over messages
|
|
822
|
+
* until EOF. exported for tests.
|
|
823
|
+
*/
|
|
824
|
+
export async function handlePlaylistzStream(
|
|
825
|
+
stream: BiStreamLike
|
|
826
|
+
): Promise<void> {
|
|
827
|
+
const peerNodeId = stream.peer_node_id();
|
|
828
|
+
try {
|
|
829
|
+
for (;;) {
|
|
830
|
+
const msg = await readMessage(stream);
|
|
831
|
+
if (msg === null) break;
|
|
832
|
+
await handleProtocolMessage(stream, peerNodeId, msg);
|
|
833
|
+
}
|
|
834
|
+
} catch (err) {
|
|
835
|
+
log.warn("p2p.protocol", "protocol stream error:", err);
|
|
836
|
+
} finally {
|
|
837
|
+
try {
|
|
838
|
+
stream.close();
|
|
839
|
+
} catch {
|
|
840
|
+
// already closed
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function handleProtocolMessage(
|
|
846
|
+
stream: BiStreamLike,
|
|
847
|
+
peerNodeId: string,
|
|
848
|
+
msg: Message
|
|
849
|
+
): Promise<void> {
|
|
850
|
+
const identity = getIdentity();
|
|
851
|
+
const settings = await getShareSettings();
|
|
852
|
+
|
|
853
|
+
switch (msg.type) {
|
|
854
|
+
case "hello": {
|
|
855
|
+
await sendMessage(stream, {
|
|
856
|
+
v: 1,
|
|
857
|
+
type: "hello_ok",
|
|
858
|
+
nodeId: identity?.node_id ?? "",
|
|
859
|
+
...(settings.name ? { name: settings.name } : {}),
|
|
860
|
+
...(settings.avatarDataUrl ? { avatarDataUrl: settings.avatarDataUrl } : {}),
|
|
861
|
+
public: settings.mode === "public",
|
|
862
|
+
});
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
case "list_playlists": {
|
|
867
|
+
const grant = await getAccessGrant(peerNodeId);
|
|
868
|
+
if (settings.mode !== "public" && !grant) {
|
|
869
|
+
await sendMessage(stream, {
|
|
870
|
+
v: 1,
|
|
871
|
+
type: "error",
|
|
872
|
+
code: "knock_required",
|
|
873
|
+
message: "this node requires a knock before listing playlists",
|
|
874
|
+
});
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
let items = await buildPlaylistItems();
|
|
878
|
+
// a grant may be scoped to specific docs
|
|
879
|
+
if (settings.mode !== "public" && grant?.docIds) {
|
|
880
|
+
const allowed = new Set(grant.docIds);
|
|
881
|
+
items = items.filter((i) => allowed.has(i.docId));
|
|
882
|
+
}
|
|
883
|
+
await sendMessage(stream, { v: 1, type: "playlists", items });
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
case "knock": {
|
|
888
|
+
const isDocAccessKnock = msg.knockType === "doc_access" && !!msg.docId;
|
|
889
|
+
const existing = await getAccessGrant(msg.nodeId);
|
|
890
|
+
|
|
891
|
+
if (isDocAccessKnock && msg.docId) {
|
|
892
|
+
// doc_access knock: auto-accept only if the doc has collaborative editing
|
|
893
|
+
// enabled. in public mode any peer qualifies; in knock mode the peer must
|
|
894
|
+
// already have an accepted grant covering this doc.
|
|
895
|
+
let isCollaborative = false;
|
|
896
|
+
try {
|
|
897
|
+
const handle = await findPlaylistDoc(msg.docId as AutomergeUrl);
|
|
898
|
+
const doc = handle.doc() as Record<string, unknown> | undefined;
|
|
899
|
+
isCollaborative = !!(doc?.collaborative);
|
|
900
|
+
} catch { /* doc not available */ }
|
|
901
|
+
|
|
902
|
+
const peerQualifies =
|
|
903
|
+
settings.mode === "public" ||
|
|
904
|
+
(existing &&
|
|
905
|
+
(!existing.docIds || existing.docIds.includes(msg.docId)));
|
|
906
|
+
|
|
907
|
+
if (isCollaborative && peerQualifies) {
|
|
908
|
+
await sendMessage(stream, {
|
|
909
|
+
v: 1,
|
|
910
|
+
type: "knock_status",
|
|
911
|
+
status: "accepted",
|
|
912
|
+
grantedDocIds: [msg.docId],
|
|
913
|
+
});
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
} else if (existing) {
|
|
917
|
+
// browse knock: check if any grant exists
|
|
918
|
+
await sendMessage(stream, {
|
|
919
|
+
v: 1,
|
|
920
|
+
type: "knock_status",
|
|
921
|
+
status: "accepted",
|
|
922
|
+
grantedDocIds: existing.docIds ?? [],
|
|
923
|
+
});
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// check for a prior knock of the same type from this node
|
|
928
|
+
const knocks = await getAllKnocks();
|
|
929
|
+
const prior = knocks.find(
|
|
930
|
+
(k) =>
|
|
931
|
+
k.direction === "inbound" &&
|
|
932
|
+
k.nodeId === msg.nodeId &&
|
|
933
|
+
(isDocAccessKnock
|
|
934
|
+
? k.knockType === "doc_access" && k.requestedDocId === msg.docId
|
|
935
|
+
: k.knockType !== "doc_access")
|
|
936
|
+
);
|
|
937
|
+
if (prior?.status === "rejected") {
|
|
938
|
+
await sendMessage(stream, {
|
|
939
|
+
v: 1,
|
|
940
|
+
type: "knock_status",
|
|
941
|
+
status: "denied",
|
|
942
|
+
});
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
if (!prior) {
|
|
946
|
+
await upsertKnock({
|
|
947
|
+
id: crypto.randomUUID(),
|
|
948
|
+
nodeId: msg.nodeId,
|
|
949
|
+
direction: "inbound",
|
|
950
|
+
name: msg.name ?? "",
|
|
951
|
+
message: msg.message ?? "",
|
|
952
|
+
status: "pending",
|
|
953
|
+
createdAt: Date.now(),
|
|
954
|
+
knockType: isDocAccessKnock ? "doc_access" : "browse",
|
|
955
|
+
...(isDocAccessKnock && msg.docId ? { requestedDocId: msg.docId } : {}),
|
|
956
|
+
});
|
|
957
|
+
notifyKnocksChanged();
|
|
958
|
+
}
|
|
959
|
+
await sendMessage(stream, {
|
|
960
|
+
v: 1,
|
|
961
|
+
type: "knock_status",
|
|
962
|
+
status: "pending",
|
|
963
|
+
});
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
case "blob_request": {
|
|
968
|
+
// only serve blobs to peers who have an accepted grant (or if public mode)
|
|
969
|
+
const blobGrant = await getAccessGrant(peerNodeId);
|
|
970
|
+
if (settings.mode !== "public" && !blobGrant) {
|
|
971
|
+
await sendMessage(stream, {
|
|
972
|
+
v: 1,
|
|
973
|
+
type: "error",
|
|
974
|
+
code: "knock_required",
|
|
975
|
+
message: "access denied: knock required before requesting blobs",
|
|
976
|
+
});
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
await serveBlobRequest(stream, msg.sha256);
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
case "knock_notify": {
|
|
984
|
+
// the peer owner has accepted our knock and is notifying us proactively.
|
|
985
|
+
// update our outbound knock record and sync the granted docs.
|
|
986
|
+
const myNodeId = getIdentity()?.node_id ?? "";
|
|
987
|
+
for (const docId of msg.docIds) {
|
|
988
|
+
try {
|
|
989
|
+
const handle = await findPlaylistDoc(docId as AutomergeUrl);
|
|
990
|
+
const doc = handle.doc();
|
|
991
|
+
if (myNodeId && doc && !(myNodeId in (doc.peers ?? {}))) {
|
|
992
|
+
handle.change((d) => addPeerToDoc(d, myNodeId));
|
|
993
|
+
await flushDoc(docId as AutomergeUrl);
|
|
994
|
+
}
|
|
995
|
+
if (!(await getDocIndexEntry(docId))) {
|
|
996
|
+
await addDocIndexEntry({
|
|
997
|
+
docId,
|
|
998
|
+
title: doc?.title || "shared playlist",
|
|
999
|
+
addedAt: Date.now(),
|
|
1000
|
+
source: "shared",
|
|
1001
|
+
remoteNodeId: msg.ownerNodeId,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
log.warn("p2p.knock", "failed to sync granted doc from notify:", docId, err);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
// mark any matching outbound knock as accepted
|
|
1009
|
+
const allKnocks = await getAllKnocks();
|
|
1010
|
+
for (const k of allKnocks) {
|
|
1011
|
+
if (k.direction === "outbound" && k.nodeId === peerNodeId && k.status === "pending") {
|
|
1012
|
+
await upsertKnock({ ...k, status: "accepted", processedAt: Date.now() });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
notifyKnocksChanged();
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
case "identity_update": {
|
|
1020
|
+
// peer changed their name or avatar - update all our docIndex entries
|
|
1021
|
+
// and access grant records that reference this peer
|
|
1022
|
+
const updates: Promise<void>[] = [];
|
|
1023
|
+
const entries = await getAllDocIndexEntries();
|
|
1024
|
+
for (const entry of entries) {
|
|
1025
|
+
if (entry.remoteNodeId !== peerNodeId) continue;
|
|
1026
|
+
const updated = {
|
|
1027
|
+
...entry,
|
|
1028
|
+
...(msg.name !== undefined ? { remoteName: msg.name } : {}),
|
|
1029
|
+
...(msg.avatarDataUrl !== undefined
|
|
1030
|
+
? { remoteAvatarDataUrl: msg.avatarDataUrl }
|
|
1031
|
+
: {}),
|
|
1032
|
+
};
|
|
1033
|
+
updates.push(addDocIndexEntry(updated));
|
|
1034
|
+
}
|
|
1035
|
+
// also update the access grant record if we have one for this peer
|
|
1036
|
+
const grant = await getAccessGrant(peerNodeId).catch(() => undefined);
|
|
1037
|
+
if (grant) {
|
|
1038
|
+
updates.push(
|
|
1039
|
+
upsertAccessGrant({
|
|
1040
|
+
...grant,
|
|
1041
|
+
...(msg.name !== undefined ? { name: msg.name } : {}),
|
|
1042
|
+
...(msg.avatarDataUrl !== undefined
|
|
1043
|
+
? { avatarDataUrl: msg.avatarDataUrl }
|
|
1044
|
+
: {}),
|
|
1045
|
+
})
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
await Promise.allSettled(updates);
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
default: {
|
|
1053
|
+
await sendMessage(stream, {
|
|
1054
|
+
v: 1,
|
|
1055
|
+
type: "error",
|
|
1056
|
+
code: "unexpected_message",
|
|
1057
|
+
message: `unexpected message type: ${msg.type}`,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** reset module state. for use in tests only. */
|
|
1064
|
+
export function _resetSharingForTests(): void {
|
|
1065
|
+
protocolHandlerRegistered = false;
|
|
1066
|
+
reconnectDone = false;
|
|
1067
|
+
leadershipWatched = false;
|
|
1068
|
+
if (reconnectIntervalId !== null) {
|
|
1069
|
+
clearInterval(reconnectIntervalId);
|
|
1070
|
+
reconnectIntervalId = null;
|
|
1071
|
+
}
|
|
1072
|
+
knockListeners.clear();
|
|
1073
|
+
}
|