Live video on the AT Protocol

app: add stream key generation

See merge request aquareum-tv/aquareum!82

Changelog: feature

+573 -228
+5 -2
js/app/components/aqlink.tsx
··· 16 16 }) { 17 17 const { isWeb } = usePlatform(); 18 18 const navigation = useNavigation<NavigationProp<ParamListBase>>(); 19 + const baseStyle: StyleProp<ViewStyle> = { 20 + display: "flex", 21 + }; 19 22 20 23 if (isWeb) { 21 24 return ( 22 - <Link style={style} to={to as any}> 25 + <Link style={[baseStyle, style]} to={to as any}> 23 26 {children} 24 27 </Link> 25 28 ); ··· 27 30 28 31 return ( 29 32 <Pressable 30 - style={style} 33 + style={[baseStyle, style]} 31 34 onPress={() => navigation.navigate(to.screen, to.params)} 32 35 > 33 36 {children}
-135
js/app/components/golive.tsx
··· 1 - import { useEffect, useState } from "react"; 2 - import { Button, Label, Paragraph, TextArea, View } from "tamagui"; 3 - import Loading from "./loading/loading"; 4 - import { useToastController } from "@tamagui/toast"; 5 - import useAquareumNode from "hooks/useAquareumNode"; 6 - import { golivePost, selectUserProfile } from "features/bluesky/blueskySlice"; 7 - import { useAppDispatch, useAppSelector } from "store/hooks"; 8 - import AQLink from "./aqlink"; 9 - import { getIdentity, selectAquareum } from "features/aquareum/aquareumSlice"; 10 - 11 - const Left = ({ children }: { children: React.ReactNode }) => { 12 - return ( 13 - <View f={2} fb={0}> 14 - {children} 15 - </View> 16 - ); 17 - }; 18 - 19 - const Right = ({ children }: { children: React.ReactNode }) => { 20 - return ( 21 - <View f={6} fb={0} alignItems="stretch"> 22 - {children} 23 - </View> 24 - ); 25 - }; 26 - type Settings = { 27 - id: string; 28 - streamer: string; 29 - title: string; 30 - }; 31 - 32 - export default function GoLive() { 33 - const toast = useToastController(); 34 - const { url } = useAquareumNode(); 35 - const profile = useAppSelector(selectUserProfile); 36 - const dispatch = useAppDispatch(); 37 - const aquareum = useAppSelector(selectAquareum); 38 - useEffect(() => { 39 - if (!aquareum.identity) { 40 - dispatch(getIdentity()); 41 - } 42 - }, [aquareum.identity]); 43 - const [title, setTitle] = useState(""); 44 - const [loading, setLoading] = useState(false); 45 - const disabled = !profile || loading || title === ""; 46 - if (!aquareum.identity) { 47 - return ( 48 - <View f={1} ai="center" jc="center" w="100%" p="$4"> 49 - <Loading /> 50 - </View> 51 - ); 52 - } 53 - const identity = aquareum.identity; 54 - return ( 55 - <View f={1} ai="center" jc="center" gap="$4" w="100%" p="$4" maxWidth={500}> 56 - <Label w="100%"> 57 - <Left> 58 - <Paragraph>Signing Key ID</Paragraph> 59 - </Left> 60 - <Right> 61 - <Paragraph>{identity.id}</Paragraph> 62 - </Right> 63 - </Label> 64 - <Label w="100%"> 65 - <Left> 66 - <Paragraph>Streamer</Paragraph> 67 - </Left> 68 - <Right> 69 - {!profile && ( 70 - <AQLink to={{ screen: "Login" }} style={{ display: "flex" }}> 71 - <Paragraph color="$accentColor">Log in with Bluesky</Paragraph> 72 - </AQLink> 73 - )} 74 - {profile && <Paragraph>@{identity.handle}</Paragraph>} 75 - </Right> 76 - </Label> 77 - <Label w="100%"> 78 - <Left> 79 - <Paragraph>ATProto DID</Paragraph> 80 - </Left> 81 - <Right> 82 - <Paragraph>{aquareum.identity.did}</Paragraph> 83 - </Right> 84 - </Label> 85 - <Label w="100%"> 86 - <Left> 87 - <Paragraph pb="$2">Title</Paragraph> 88 - </Left> 89 - <Right> 90 - <TextArea 91 - value={title} 92 - onChangeText={setTitle} 93 - w="100%" 94 - size="$4" 95 - minHeight={100} 96 - /> 97 - </Right> 98 - </Label> 99 - <View gap="$2" w="100%"> 100 - <Button 101 - disabled={disabled} 102 - opacity={disabled ? 0.5 : 1} 103 - w="100%" 104 - size="$4" 105 - onPress={async () => { 106 - setLoading(true); 107 - if (!url) { 108 - throw new Error("No node URL"); 109 - } 110 - try { 111 - await dispatch( 112 - golivePost({ 113 - nodeUrl: url, 114 - signingKey: identity.id, 115 - text: title, 116 - }), 117 - ); 118 - toast.show("Posted!", { 119 - message: `Great success!`, 120 - }); 121 - } catch (e) { 122 - toast.show("Error creating post", { 123 - message: e.mesasge, 124 - }); 125 - } finally { 126 - setLoading(false); 127 - } 128 - }} 129 - > 130 - {loading ? "Loading..." : "Save"} 131 - </Button> 132 - </View> 133 - </View> 134 - ); 135 - }
-1
js/app/components/login/login.tsx
··· 16 16 const userProfile = useAppSelector(selectUserProfile); 17 17 const pds = useAppSelector(selectPDS); 18 18 const loginState = useAppSelector(selectLogin); 19 - console.log("pds", pds); 20 19 const [open, setOpen] = useState(false); 21 20 const onOpenChange = (open: boolean) => { 22 21 setOpen(open);
+29 -5
js/app/components/player/use-webrtc.tsx
··· 1 1 import { useEffect, useState } from "react"; 2 2 import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives"; 3 3 import { usePlayerActions } from "features/player/playerSlice"; 4 - import { useAppDispatch } from "store/hooks"; 4 + import { useAppDispatch, useAppSelector } from "store/hooks"; 5 + import { 6 + createStreamKeyRecord, 7 + selectStoredKey, 8 + } from "features/bluesky/blueskySlice"; 5 9 6 10 export default function useWebRTC(endpoint: string) { 7 11 const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); ··· 54 58 export async function negotiateConnectionWithClientOffer( 55 59 peerConnection: RTCPeerConnection, 56 60 endpoint: string, 61 + bearerToken?: string, 57 62 ) { 58 63 /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */ 59 64 const offer = await peerConnection.createOffer({ ··· 80 85 * and what kind of media client and server have negotiated to exchange. 81 86 */ 82 87 console.log(`posting sdp offer: ${endpoint}`); 83 - let response = await postSDPOffer(endpoint, ofr.sdp); 88 + let response = await postSDPOffer(endpoint, ofr.sdp, bearerToken); 84 89 if (response.status === 201) { 85 90 let answerSDP = await response.text(); 86 91 await peerConnection.setRemoteDescription( ··· 104 109 } 105 110 } 106 111 107 - async function postSDPOffer(endpoint: string, data: string) { 112 + async function postSDPOffer( 113 + endpoint: string, 114 + data: string, 115 + bearerToken?: string, 116 + ) { 108 117 return await fetch(endpoint, { 109 118 method: "POST", 110 119 mode: "cors", 111 120 headers: { 112 121 "content-type": "application/sdp", 122 + ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}), 113 123 }, 114 124 body: data, 115 125 }); ··· 146 156 const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); 147 157 const { ingestConnectionState } = usePlayerActions(); 148 158 const dispatch = useAppDispatch(); 159 + const storedKey = useAppSelector(selectStoredKey); 160 + useEffect(() => { 161 + if (storedKey) { 162 + return; 163 + } 164 + dispatch(createStreamKeyRecord({ store: true })); 165 + }, [storedKey]); 149 166 useEffect(() => { 150 167 if (!mediaStream) { 168 + return; 169 + } 170 + if (!storedKey) { 151 171 return; 152 172 } 153 173 console.log("creating peer connection"); ··· 165 185 } 166 186 }); 167 187 peerConnection.addEventListener("negotiationneeded", (ev) => { 168 - negotiateConnectionWithClientOffer(peerConnection, endpoint); 188 + negotiateConnectionWithClientOffer( 189 + peerConnection, 190 + endpoint, 191 + storedKey.privateKey, 192 + ); 169 193 }); 170 194 171 195 return () => { 172 196 peerConnection.close(); 173 197 }; 174 - }, [endpoint, mediaStream]); 198 + }, [endpoint, mediaStream, storedKey]); 175 199 return [mediaStream, setMediaStream]; 176 200 }
+9 -3
js/app/components/player/video.tsx
··· 20 20 import useWebRTC, { useWebRTCIngest } from "./use-webrtc"; 21 21 import useAquareumNode from "hooks/useAquareumNode"; 22 22 import { selectPlayer } from "features/player/playerSlice"; 23 - import { useAppSelector } from "store/hooks"; 23 + import { useAppDispatch, useAppSelector } from "store/hooks"; 24 + import { selectStoredKey } from "features/bluesky/blueskySlice"; 24 25 25 26 type VideoProps = PlayerProps & { url: string }; 26 27 ··· 124 125 backgroundColor: "transparent", 125 126 width: "100%", 126 127 height: "100%", 127 - // transform: props.ingest ? "scaleX(-1)" : undefined, 128 + transform: props.ingest ? "scaleX(-1)" : undefined, 128 129 }} 129 130 /> 130 131 </View> ··· 212 213 export function WebcamIngestPlayer( 213 214 props: VideoProps & { videoRef: RefObject<HTMLVideoElement> }, 214 215 ) { 216 + const dispatch = useAppDispatch(); 215 217 const player = useAppSelector(selectPlayer); 218 + const storedKey = useAppSelector(selectStoredKey); 216 219 const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( 217 220 null, 218 221 ); ··· 252 255 if (!localMediaStream) { 253 256 return; 254 257 } 258 + if (!storedKey) { 259 + return; 260 + } 255 261 setRemoteMediaStream(localMediaStream); 256 - }, [localMediaStream, player.ingestStarting]); 262 + }, [localMediaStream, player.ingestStarting, storedKey]); 257 263 258 264 useEffect(() => { 259 265 if (!videoElement) {
+56
js/app/features/base/baseSlice.tsx
··· 1 + import { createAppSlice } from "../../hooks/createSlice"; 2 + import Storage from "../../storage"; 3 + export const STORED_KEY_KEY = "storedKey"; 4 + 5 + export interface StreamKey { 6 + privateKey: string; 7 + did: string; 8 + address: string; 9 + } 10 + 11 + export interface BaseState { 12 + hydrated: boolean; 13 + } 14 + 15 + const initialState: BaseState = { 16 + hydrated: false, 17 + }; 18 + 19 + export const baseSlice = createAppSlice({ 20 + name: "base", 21 + initialState, 22 + reducers: (create) => ({ 23 + hydrate: create.asyncThunk( 24 + async () => { 25 + let storedKey: StreamKey | null = null; 26 + // Async operation would go here 27 + try { 28 + const storedKeyStr = await Storage.getItem(STORED_KEY_KEY); 29 + if (storedKeyStr) { 30 + storedKey = JSON.parse(storedKeyStr); 31 + } 32 + } catch (e) { 33 + // we don't have one i guess 34 + } 35 + return { storedKey }; 36 + }, 37 + { 38 + pending: (state) => { 39 + state.hydrated = false; 40 + }, 41 + fulfilled: (state) => { 42 + state.hydrated = true; 43 + }, 44 + rejected: (state) => { 45 + state.hydrated = false; 46 + }, 47 + }, 48 + ), 49 + }), 50 + selectors: { 51 + selectHydrated: (state) => state.hydrated, 52 + }, 53 + }); 54 + 55 + export const { hydrate } = baseSlice.actions; 56 + export const { selectHydrated } = baseSlice.selectors;
+108
js/app/features/bluesky/blueskySlice.tsx
··· 6 6 import Storage from "storage"; 7 7 import { createAppSlice } from "../../hooks/createSlice"; 8 8 import createOAuthClient, { AquareumOAuthClient } from "./oauthClient"; 9 + import { Secp256k1Keypair, bytesToMultibase } from "@atproto/crypto"; 10 + import { privateKeyToAccount } from "viem/accounts"; 11 + import { StreamKey } from "features/base/baseSlice"; 12 + import { hydrate, STORED_KEY_KEY } from "features/base/baseSlice"; 13 + 9 14 export interface BlueskyState { 10 15 status: "start" | "loggedIn" | "loggedOut"; 11 16 oauthState: null | string; ··· 22 27 loading: boolean; 23 28 error: null | string; 24 29 }; 30 + newKey: null | StreamKey; 31 + storedKey: null | StreamKey; 25 32 } 26 33 27 34 const initialState: BlueskyState = { ··· 40 47 loading: false, 41 48 error: null, 42 49 }, 50 + newKey: null, 51 + storedKey: null, 43 52 }; 44 53 45 54 export const blueskySlice = createAppSlice({ 46 55 name: "bluesky", 47 56 initialState, 57 + extraReducers: (builder) => { 58 + builder.addCase(hydrate.fulfilled, (state, action) => { 59 + return { 60 + ...state, 61 + storedKey: action.payload.storedKey, 62 + }; 63 + }); 64 + }, 48 65 reducers: (create) => ({ 49 66 loadOAuthClient: create.asyncThunk( 50 67 async (_, { getState }) => { ··· 313 330 }, 314 331 }, 315 332 ), 333 + 334 + createStreamKeyRecord: create.asyncThunk( 335 + async ({ store }: { store: boolean }, thunkAPI) => { 336 + const { bluesky } = thunkAPI.getState() as { 337 + bluesky: BlueskyState; 338 + }; 339 + if (!bluesky.pdsAgent) { 340 + throw new Error("No agent"); 341 + } 342 + const did = bluesky.oauthSession?.did; 343 + if (!did) { 344 + throw new Error("No DID"); 345 + } 346 + const profile = bluesky.profiles[did]; 347 + if (!profile) { 348 + throw new Error("No profile"); 349 + } 350 + if (!did) { 351 + throw new Error("No DID"); 352 + } 353 + const keypair = await Secp256k1Keypair.create({ exportable: true }); 354 + const exportedKey = await keypair.export(); 355 + const didBytes = new TextEncoder().encode(did); 356 + const combinedKey = new Uint8Array([...exportedKey, ...didBytes]); 357 + const multibaseKey = bytesToMultibase(combinedKey, "base58btc"); 358 + const hexKey = Array.from(exportedKey) 359 + .map((b) => b.toString(16).padStart(2, "0")) 360 + .join(""); 361 + const account = await privateKeyToAccount(`0x${hexKey}`); 362 + const newKey = { 363 + privateKey: multibaseKey, 364 + did: keypair.did(), 365 + address: account.address.toLowerCase(), 366 + }; 367 + const record = { 368 + signingKey: keypair.did(), 369 + }; 370 + await bluesky.pdsAgent.com.atproto.repo.createRecord({ 371 + repo: did, 372 + collection: "place.stream.key", 373 + record, 374 + }); 375 + if (store) { 376 + await Storage.setItem(STORED_KEY_KEY, JSON.stringify(newKey)); 377 + } 378 + return newKey; 379 + }, 380 + { 381 + pending: (state) => { 382 + console.log("golivePost pending"); 383 + }, 384 + fulfilled: (state, action) => { 385 + return { 386 + ...state, 387 + newKey: action.payload, 388 + storedKey: action.meta.arg.store ? action.payload : null, 389 + }; 390 + }, 391 + rejected: (state, action) => { 392 + console.error("getProfile rejected", action.error); 393 + // state.status = "failed"; 394 + }, 395 + }, 396 + ), 397 + 398 + clearStreamKeyRecord: create.reducer((state) => { 399 + return { 400 + ...state, 401 + newKey: null, 402 + }; 403 + }), 404 + 316 405 setPDS: create.asyncThunk( 317 406 async (pds: string, thunkAPI) => { 318 407 await Storage.setItem("pdsURL", pds); ··· 361 450 selectPDS: (bluesky) => bluesky.pds, 362 451 selectLogin: (bluesky) => bluesky.login, 363 452 selectProfiles: (bluesky) => bluesky.profiles, 453 + selectStoredKey: (bluesky) => bluesky.storedKey, 364 454 selectUserProfile: (bluesky) => { 365 455 const did = bluesky.oauthSession?.did; 366 456 if (!did) return null; 367 457 return bluesky.profiles[did]; 368 458 }, 459 + selectIsReady: (bluesky) => { 460 + if (bluesky.status === "start") { 461 + return false; 462 + } else if (bluesky.status === "loggedOut") { 463 + return true; 464 + } 465 + if (!bluesky.oauthSession) { 466 + return false; 467 + } 468 + const profile = blueskySlice.selectors.selectUserProfile({ bluesky }); 469 + if (!profile) { 470 + return false; 471 + } 472 + return true; 473 + }, 369 474 }, 370 475 }); 371 476 ··· 378 483 golivePost, 379 484 oauthCallback, 380 485 setPDS, 486 + createStreamKeyRecord, 487 + clearStreamKeyRecord, 381 488 } = blueskySlice.actions; 382 489 383 490 // Selectors returned by `slice.selectors` take the root state as their first argument. ··· 387 494 selectUserProfile, 388 495 selectPDS, 389 496 selectLogin, 497 + selectStoredKey, 390 498 } = blueskySlice.selectors;
+2
js/app/package.json
··· 27 27 "dependencies": { 28 28 "@aquareum/atproto-oauth-client-react-native": "^0.0.1", 29 29 "@atproto-labs/pipe": "^0.1.0", 30 + "@atproto/crypto": "^0.4.2", 30 31 "@atproto/jwk-jose": "^0.1.2", 31 32 "@atproto/oauth-client": "^0.3.1", 32 33 "@bacons/text-decoder": "^0.0.0", ··· 63 64 "expo-web-browser": "^14.0.1", 64 65 "hls.js": "^1.5.17", 65 66 "jose": "^5.9.6", 67 + "multiformats": "^13.3.1", 66 68 "react": "18.3.1", 67 69 "react-dom": "18.3.1", 68 70 "react-native": "0.76.2",
+29 -12
js/app/src/router.tsx
··· 29 29 Pressable, 30 30 StatusBar, 31 31 } from "react-native"; 32 - import { useAppSelector } from "store/hooks"; 32 + import { useAppDispatch, useAppSelector } from "store/hooks"; 33 33 import { Text, useTheme, View } from "tamagui"; 34 34 import AppReturnScreen from "./screens/app-return"; 35 - import GoLiveScreen from "./screens/golive"; 36 35 import LiveScreen from "./screens/live"; 37 36 import MultiScreen from "./screens/multi"; 38 37 import StreamScreen from "./screens/stream"; 39 38 import SupportScreen from "./screens/support"; 40 39 import WebcamScreen from "./screens/webcam"; 40 + import StreamKeyScreen from "./screens/stream-key"; 41 + import { hydrate, selectHydrated } from "features/base/baseSlice"; 41 42 function HomeScreen() { 42 43 return ( 43 44 <View f={1}> ··· 65 66 GoLive: "golive", 66 67 Live: "live", 67 68 Webcam: "live/webcam", 69 + StreamKey: "live/stream-key", 68 70 Login: "login", 69 71 AppReturn: "app-return/:scheme", 70 72 }, ··· 141 143 const theme = useTheme(); 142 144 const { isWeb, isElectron } = usePlatform(); 143 145 const navigation = useNavigation(); 146 + const dispatch = useAppDispatch(); 144 147 useEffect(() => { 148 + dispatch(hydrate()); 145 149 // const params = new URLSearchParams(document.location.search); 146 150 // if (params.has("code")) { 147 151 // navigation.dispatch( ··· 152 156 // ); 153 157 // } 154 158 }, []); 159 + const hydrated = useAppSelector(selectHydrated); 160 + if (!hydrated) { 161 + return <View />; 162 + } 155 163 return ( 156 164 <> 157 165 <StatusBar backgroundColor={theme.background.val} /> ··· 161 169 headerLeft: () => <NavigationButton />, 162 170 headerRight: () => <AvatarButton />, 163 171 drawerActiveTintColor: theme.accentColor.val, 172 + unmountOnBlur: true, 164 173 }} 165 174 > 166 175 <Drawer.Screen ··· 168 177 component={MainTab} 169 178 options={{ 170 179 drawerIcon: () => <Home />, 180 + drawerLabel: () => <Text>Home</Text>, 171 181 headerTitle: "Aquareum", 172 182 headerShown: isWeb, 173 183 title: "Aquareum", ··· 194 204 <Drawer.Screen 195 205 name="Settings" 196 206 component={Settings} 197 - options={{ drawerIcon: () => <SettingsIcon /> }} 207 + options={{ 208 + drawerIcon: () => <SettingsIcon />, 209 + drawerLabel: () => <Text>Settings</Text>, 210 + }} 198 211 /> 199 212 <Drawer.Screen 200 213 name="Multi" ··· 208 221 name="Support" 209 222 component={SupportScreen} 210 223 options={{ 211 - drawerLabel: () => null, 224 + drawerLabel: () => <Text>Support</Text>, 212 225 drawerItemStyle: { display: "none" }, 213 226 }} 214 227 /> ··· 237 250 }} 238 251 /> 239 252 <Drawer.Screen 253 + name="StreamKey" 254 + component={StreamKeyScreen} 255 + options={{ 256 + drawerLabel: () => null, 257 + drawerItemStyle: { display: "none" }, 258 + }} 259 + /> 260 + <Drawer.Screen 240 261 name="Login" 241 262 component={Login} 242 - options={{ drawerIcon: () => <LogIn /> }} 263 + options={{ 264 + drawerIcon: () => <LogIn />, 265 + drawerLabel: () => <Text>Login</Text>, 266 + }} 243 267 /> 244 - {isElectron && ( 245 - <Drawer.Screen 246 - name="GoLive" 247 - component={GoLiveScreen} 248 - options={{ headerTitle: "Go Live", drawerIcon: () => <Video /> }} 249 - /> 250 - )} 251 268 </Drawer.Navigator> 252 269 </> 253 270 );
-10
js/app/src/screens/golive.tsx
··· 1 - import GoLive from "components/golive"; 2 - import { View } from "tamagui"; 3 - 4 - export default function GoLiveScreen() { 5 - return ( 6 - <View f={1} ai="center" jc="center"> 7 - <GoLive /> 8 - </View> 9 - ); 10 - }
+23 -10
js/app/src/screens/live.tsx
··· 1 1 import { Camera, FerrisWheel } from "@tamagui/lucide-icons"; 2 2 import AQLink from "components/aqlink"; 3 3 import React from "react"; 4 - import { Button, H6, Text, View } from "tamagui"; 4 + import { H6, Text, View } from "tamagui"; 5 5 const elems = [ 6 6 { 7 7 title: "Stream your camera!", ··· 11 11 { 12 12 title: "Stream from OBS!", 13 13 Icon: FerrisWheel, 14 - to: "Webcam", 14 + to: "StreamKey", 15 15 }, 16 16 ]; 17 17 18 18 export default function StreamScreen({ route }) { 19 19 return ( 20 - <View f={1} jc="space-around" ai="center" padding="$3" flexDirection="row"> 21 - <View f={1} maxWidth={250}> 20 + <View f={1} jc="space-around" ai="stretch" padding="$3" flexDirection="row"> 21 + <View f={1} maxWidth={250} alignItems="stretch" justifyContent="center"> 22 22 {elems.map(({ Icon, title, to }, i) => ( 23 23 <React.Fragment key={i}> 24 - <AQLink to={{ screen: to }} style={{ display: "flex" }}> 25 - <Button f={1} padding="$6" backgroundColor="$accentColor"> 26 - <View f={1} flexDirection="row" ai="center" jc="space-between"> 27 - <Icon padding="$5" size={48} marginLeft={-20} /> 28 - <Text>{title}</Text> 24 + <AQLink 25 + to={{ screen: to }} 26 + style={{ display: "flex", flex: 1, flexGrow: 0, flexBasis: 75 }} 27 + > 28 + <View 29 + f={1} 30 + flexDirection="row" 31 + ai="center" 32 + jc="space-between" 33 + backgroundColor="$accentColor" 34 + // padding="$5" 35 + borderRadius="$10" 36 + > 37 + <View padding="$5" paddingRight={0}> 38 + <Icon size={48} /> 29 39 </View> 30 - </Button> 40 + <Text f={1} textAlign="right" paddingRight="$5"> 41 + {title} 42 + </Text> 43 + </View> 31 44 </AQLink> 32 45 {i < elems.length - 1 && ( 33 46 <View jc="center" ai="center">
+120
js/app/src/screens/stream-key.tsx
··· 1 + import { useToastController } from "@tamagui/toast"; 2 + import Loading from "components/loading/loading"; 3 + import { 4 + clearStreamKeyRecord, 5 + createStreamKeyRecord, 6 + selectUserProfile, 7 + } from "features/bluesky/blueskySlice"; 8 + import { useEffect, useState } from "react"; 9 + import { useAppDispatch, useAppSelector } from "store/hooks"; 10 + import { View, Paragraph, Button } from "tamagui"; 11 + 12 + const Row = ({ children }: { children: React.ReactNode }) => { 13 + return ( 14 + <View w="100%" f={1} fd="row" padding="$4"> 15 + {children} 16 + </View> 17 + ); 18 + }; 19 + 20 + const Left = ({ children }: { children: React.ReactNode }) => { 21 + return ( 22 + <View f={2} fb={0}> 23 + {children} 24 + </View> 25 + ); 26 + }; 27 + 28 + const Right = ({ children }: { children: React.ReactNode }) => { 29 + return ( 30 + <View f={6} alignItems="stretch" fb={0}> 31 + {children} 32 + </View> 33 + ); 34 + }; 35 + 36 + export default function StreamKeyScreen() { 37 + const userProfile = useAppSelector(selectUserProfile); 38 + const url = useAppSelector((state) => state.aquareum.url); 39 + 40 + if (!userProfile) { 41 + return <Loading />; 42 + } 43 + return ( 44 + <View f={1} ai="center" jc="center" gap="$4" w="100%" p="$4"> 45 + <View w="100%" maxWidth={500}> 46 + <Row> 47 + <Left> 48 + <Paragraph>Service</Paragraph> 49 + </Left> 50 + <Right> 51 + <Paragraph>WHIP</Paragraph> 52 + </Right> 53 + </Row> 54 + <Row> 55 + <Left> 56 + <Paragraph>Server</Paragraph> 57 + </Left> 58 + <Right> 59 + <Paragraph>{url}</Paragraph> 60 + </Right> 61 + </Row> 62 + <Row> 63 + <Left> 64 + <Paragraph>Bearer Token</Paragraph> 65 + </Left> 66 + <Right> 67 + <StreamKey /> 68 + </Right> 69 + </Row> 70 + </View> 71 + </View> 72 + ); 73 + } 74 + 75 + export function StreamKey() { 76 + const dispatch = useAppDispatch(); 77 + const [generating, setGenerating] = useState(false); 78 + const newKey = useAppSelector((state) => state.bluesky.newKey); 79 + const toast = useToastController(); 80 + useEffect(() => { 81 + if (!newKey) { 82 + return; 83 + } 84 + (async () => { 85 + try { 86 + await navigator.clipboard.writeText(newKey.privateKey); 87 + toast.show("Copied!", { 88 + message: "Bearer token copied to clipboard", 89 + }); 90 + } catch (e) { 91 + // not allowed. oh well. 92 + } 93 + })(); 94 + return () => { 95 + dispatch(clearStreamKeyRecord()); 96 + }; 97 + }, [newKey]); 98 + if (generating) { 99 + return <Loading />; 100 + } 101 + if (newKey) { 102 + return <Paragraph fontFamily="$mono">{newKey.privateKey}</Paragraph>; 103 + } 104 + return ( 105 + <Button 106 + onPress={async () => { 107 + try { 108 + setGenerating(true); 109 + await dispatch(createStreamKeyRecord({ store: false })); 110 + } catch (e) { 111 + console.error("failed to generate stream key", e); 112 + } finally { 113 + setGenerating(false); 114 + } 115 + }} 116 + > 117 + Generate Stream Key 118 + </Button> 119 + ); 120 + }
+2
js/app/store/store.tsx
··· 2 2 import { combineSlices, configureStore } from "@reduxjs/toolkit"; 3 3 import { setupListeners } from "@reduxjs/toolkit/query"; 4 4 import { aquareumSlice } from "features/aquareum/aquareumSlice"; 5 + import { baseSlice } from "features/base/baseSlice"; 5 6 import { blueskySlice } from "features/bluesky/blueskySlice"; 6 7 import { platformSlice } from "features/platform/platformSlice"; 7 8 import { playerSlice } from "features/player/playerSlice"; ··· 11 12 aquareumSlice, 12 13 platformSlice, 13 14 playerSlice, 15 + baseSlice, 14 16 ); 15 17 16 18 export type RootState = ReturnType<typeof rootReducer>;
+5
pkg/api/api.go
··· 126 126 router.Handler("PATCH", "/api/*resource", apiRouter) 127 127 router.Handler("DELETE", "/api/*resource", apiRouter) 128 128 router.GET("/dl/*params", a.HandleAppDownload(ctx)) 129 + router.POST("/", a.HandleWebRTCIngest(ctx)) 129 130 if a.CLI.FrontendProxy != "" { 130 131 u, err := url.Parse(a.CLI.FrontendProxy) 131 132 if err != nil { ··· 147 148 } 148 149 router.NotFound = a.FileHandler(ctx, http.FileServer(AppHostingFS{http.FS(files)})) 149 150 } 151 + // needed because the WebRTC handler issues 405s from / otherwise 152 + router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 153 + router.NotFound.ServeHTTP(w, r) 154 + }) 150 155 handler := sloghttp.Recovery(router) 151 156 handler = cors.AllowAll().Handler(handler) 152 157 handler = sloghttp.New(slog.Default())(handler)
+57 -1
pkg/api/playback.go
··· 4 4 "bufio" 5 5 "bytes" 6 6 "context" 7 + "crypto" 7 8 "fmt" 8 9 "io" 9 10 "net/http" ··· 14 15 "aquareum.tv/aquareum/pkg/aqtime" 15 16 "aquareum.tv/aquareum/pkg/atproto" 16 17 "aquareum.tv/aquareum/pkg/errors" 18 + apierrors "aquareum.tv/aquareum/pkg/errors" 17 19 "aquareum.tv/aquareum/pkg/log" 18 20 "aquareum.tv/aquareum/pkg/media" 21 + "github.com/decred/dcrd/dcrec/secp256k1" 19 22 "github.com/julienschmidt/httprouter" 23 + "github.com/mr-tron/base58" 20 24 "github.com/pion/webrtc/v4" 21 25 "golang.org/x/sync/errgroup" 22 26 ) ··· 161 165 } 162 166 } 163 167 168 + const BEARER_PREFIX = "Bearer " 169 + const KEY_PREFIX = "0x" 170 + 164 171 func (a *AquareumAPI) HandleWebRTCIngest(ctx context.Context) httprouter.Handle { 165 172 return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 173 + ct := r.Header.Get("Content-Type") 174 + if ct != "application/sdp" { 175 + errors.WriteHTTPBadRequest(w, "invalid content type", nil) 176 + return 177 + } 178 + auth := r.Header.Get("Authorization") 179 + if auth == "" { 180 + errors.WriteHTTPUnauthorized(w, "authorization header required", nil) 181 + return 182 + } 183 + if !strings.HasPrefix(auth, BEARER_PREFIX) { 184 + errors.WriteHTTPUnauthorized(w, "invalid authorization header (needs Bearer prefix)", nil) 185 + return 186 + } 187 + encoded := auth[len(BEARER_PREFIX):] 188 + if len(encoded) < 2 || encoded[0] != 'z' { 189 + errors.WriteHTTPUnauthorized(w, "invalid authorization key (not a multibase base58btc string)", nil) 190 + return 191 + } 192 + data, err := base58.Decode(encoded[1:]) 193 + if err != nil { 194 + errors.WriteHTTPUnauthorized(w, "invalid authorization key (not a multibase base58btc string)", nil) 195 + return 196 + } 197 + addrBytes := data[:32] 198 + didBytes := data[32:] 199 + 200 + key, _ := secp256k1.PrivKeyFromBytes(addrBytes) 201 + if key == nil { 202 + errors.WriteHTTPUnauthorized(w, "invalid authorization key (not valid secp256k1)", nil) 203 + return 204 + } 205 + var signer crypto.Signer = key.ToECDSA() 206 + 207 + did := string(didBytes) 208 + fmt.Println("did", did) 209 + 210 + mediaSigner, err := media.MakeMediaSigner(ctx, a.CLI, "fixme-media-signer", signer, a.Model) 211 + if err != nil { 212 + errors.WriteHTTPUnauthorized(w, "invalid authorization key (not valid secp256k1)", err) 213 + return 214 + } 215 + 216 + _, err = atproto.SyncBlueskyRepo(ctx, did, a.Model) 217 + if err != nil { 218 + apierrors.WriteHTTPInternalServerError(w, "could not resolve aquareum key", err) 219 + return 220 + } 221 + 166 222 // user := p.ByName("user") 167 223 // if user == "" { 168 224 // errors.WriteHTTPBadRequest(w, "user required", nil) ··· 179 235 return 180 236 } 181 237 offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} 182 - answer, err := a.MediaManager.WebRTCIngest(ctx, &offer, a.MediaSigner) 238 + answer, err := a.MediaManager.WebRTCIngest(ctx, &offer, mediaSigner) 183 239 if err != nil { 184 240 errors.WriteHTTPInternalServerError(w, "error playing back", err) 185 241 return
+43 -13
pkg/atproto/atproto.go
··· 4 4 "bytes" 5 5 "context" 6 6 "fmt" 7 + "strings" 7 8 "sync" 8 9 9 10 "aquareum.tv/aquareum/pkg/aqhttp" 11 + "aquareum.tv/aquareum/pkg/crypto/aqpub" 10 12 "aquareum.tv/aquareum/pkg/log" 11 13 "aquareum.tv/aquareum/pkg/model" 12 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 13 16 "github.com/bluesky-social/indigo/atproto/identity" 14 17 "github.com/bluesky-social/indigo/atproto/syntax" 15 18 "github.com/bluesky-social/indigo/repo" ··· 21 24 ) 22 25 23 26 var SyncGetRepo = comatproto.SyncGetRepo 24 - var AQUAREUM_KEY = "tv.aquareum.key" 27 + var STREAMPLACE_COLLECTION = "place.stream.key" 28 + var STREAMPLACE_SIGNING_KEY = "signingKey" 29 + 30 + const DID_KEY_PREFIX = "did:key" 31 + const ADDRESS_KEY_PREFIX = "0x" 25 32 26 33 // handleLocks provides per-handle synchronization 27 34 var handleLocks = struct { ··· 51 58 return "", fmt.Errorf("failed to get repo for %s: %w", handle, err) 52 59 } 53 60 if repo != nil { 54 - return repo.AquareumKey, nil 61 + return repo.SigningKey, nil 55 62 } 56 63 return SyncBlueskyRepo(ctx, handle, mod) 57 64 } ··· 115 122 } 116 123 if oldRoot.Equals(root) { 117 124 log.Log(ctx, "no changes to repo", "root", root) 118 - return oldRepo.AquareumKey, nil 125 + return oldRepo.SigningKey, nil 119 126 } 120 127 } 121 128 ··· 137 144 processed := 0 138 145 var key string 139 146 if oldRepo != nil { 140 - key = oldRepo.AquareumKey 147 + key = oldRepo.SigningKey 141 148 } 142 149 bs = r.Blockstore() 143 150 cst := util.CborStore(bs) ··· 157 164 if !ok { 158 165 continue 159 166 } 160 - if typ != "app.bsky.feed.post" { 167 + if typ != STREAMPLACE_COLLECTION { 161 168 continue 162 169 } 163 170 processed += 1 164 - aquareumKeyAny, ok := rec[AQUAREUM_KEY] 171 + aquareumKeyAny, ok := rec[STREAMPLACE_SIGNING_KEY] 165 172 if !ok { 166 173 continue 167 174 } ··· 172 179 key = aquareumKey 173 180 } 174 181 log.Log(ctx, "processed new posts", "postCount", processed) 182 + 183 + var aqk aqpub.Pub 184 + if strings.HasPrefix(key, DID_KEY_PREFIX) { 185 + pubKey, err := atcrypto.ParsePublicDIDKey(key) 186 + if err != nil { 187 + return "", fmt.Errorf("failed to parse multibase key %s: %w", key, err) 188 + } 189 + aqk, err = aqpub.FromBytes(pubKey.UncompressedBytes()) 190 + if err != nil { 191 + return "", fmt.Errorf("failed to parse public key for %s: %w", handle, err) 192 + } 193 + } else if strings.HasPrefix(key, ADDRESS_KEY_PREFIX) { 194 + aqk, err = aqpub.FromHexString(key) 195 + if err != nil { 196 + return "", fmt.Errorf("failed to parse public key for %s: %w", handle, err) 197 + } 198 + } else { 199 + return "", fmt.Errorf("invalid key format for %s: %s", handle, key) 200 + } 201 + if err != nil { 202 + return "", fmt.Errorf("failed to parse public key for %s: %w", handle, err) 203 + } 204 + addr := aqk.String() 175 205 newRepo := model.Repo{ 176 - DID: ident.DID.String(), 177 - PDS: ident.PDSEndpoint(), 178 - Version: sc.Rev, 179 - AquareumKey: key, 180 - RootCID: root.String(), 181 - Handle: handle, 206 + DID: ident.DID.String(), 207 + PDS: ident.PDSEndpoint(), 208 + Version: sc.Rev, 209 + SigningKey: addr, 210 + RootCID: root.String(), 211 + Handle: handle, 182 212 } 183 213 err = mod.UpdateRepo(&newRepo) 184 214 if err != nil { 185 215 return "", fmt.Errorf("failed to update DID record for %s: %w", sc.Did, err) 186 216 } 187 217 188 - return key, nil 218 + return addr, nil 189 219 } 190 220 191 221 var ResolveIdent = resolveIdent
+6 -3
pkg/atproto/atproto_test.go
··· 16 16 17 17 func TestKeyResolution(t *testing.T) { 18 18 // i wrote these tests before i renamed this and i don't wanna re-export, okay? 19 - oldAquareumKey := AQUAREUM_KEY 20 - defer func() { AQUAREUM_KEY = oldAquareumKey }() 21 - AQUAREUM_KEY = "aquareumKey" 19 + oldAquareumCollection := STREAMPLACE_COLLECTION 20 + oldAquareumKey := STREAMPLACE_SIGNING_KEY 21 + defer func() { STREAMPLACE_COLLECTION = oldAquareumCollection }() 22 + defer func() { STREAMPLACE_SIGNING_KEY = oldAquareumKey }() 23 + STREAMPLACE_COLLECTION = "app.bsky.feed.post" 24 + STREAMPLACE_SIGNING_KEY = "aquareumKey" 22 25 23 26 dir, err := os.MkdirTemp("", "atproto-test-*") 24 27 require.NoError(t, err)
+4 -4
pkg/cmd/aquareum.go
··· 118 118 fs.StringVar(&cli.PKCS11KeypairID, "pkcs11-keypair-id", "", "id of signing keypair on PKCS11 token") 119 119 fs.StringVar(&cli.StreamerName, "streamer-name", "", "name of the person streaming from this aquareum node") 120 120 fs.StringVar(&cli.FrontendProxy, "dev-frontend-proxy", "", "(FOR DEVELOPMENT ONLY) proxy frontend requests to this address instead of using the bundled frontend") 121 - cli.AddressSliceFlag(fs, &cli.AllowedStreams, "allowed-streams", "", "comma-separated list of addresses that this node will replicate") 121 + cli.StringSliceFlag(fs, &cli.AllowedStreams, "allowed-streams", "", "comma-separated list of addresses or atproto DIDs that this node will replicate") 122 122 cli.StringSliceFlag(fs, &cli.Peers, "peers", "", "other aquareum nodes to replicate to") 123 123 cli.DebugFlag(fs, &cli.Debug, "debug", "", "modified log verbosity for specific functions or files in form func=ToHLS:3,file=gstreamer.go:4") 124 124 fs.BoolVar(&cli.TestStream, "test-stream", false, "run a built-in test stream on boot") ··· 250 250 signer = hwsigner 251 251 } 252 252 var rep replication.Replicator = &boring.BoringReplicator{Peers: cli.Peers} 253 - mm, err := media.MakeMediaManager(ctx, &cli, signer, rep) 253 + mod, err := model.MakeDB(cli.DBPath) 254 254 if err != nil { 255 255 return err 256 256 } 257 - mod, err := model.MakeDB(cli.DBPath) 257 + mm, err := media.MakeMediaManager(ctx, &cli, signer, rep, mod) 258 258 if err != nil { 259 259 return err 260 260 } ··· 403 403 if err != nil { 404 404 return err 405 405 } 406 - cli.AllowedStreams = append(cli.AllowedStreams, testMediaSigner.Pub) 406 + cli.AllowedStreams = append(cli.AllowedStreams, testMediaSigner.Pub.String()) 407 407 a.Aliases["self-test"] = testMediaSigner.Pub.String() 408 408 group.Go(func() error { 409 409 return mm.TestSource(ctx, testMediaSigner)
+1 -1
pkg/config/config.go
··· 72 72 PKCS11KeypairID string 73 73 StreamerName string 74 74 Debug map[string]map[string]int 75 - AllowedStreams []aqpub.Pub 75 + AllowedStreams []string 76 76 Peers []string 77 77 TestStream bool 78 78 FrontendProxy string
+9
pkg/crypto/aqpub/aqpub.go
··· 34 34 return &pub{addr}, nil 35 35 } 36 36 37 + func FromBytes(bs []byte) (Pub, error) { 38 + pubkey, err := secp256k1.ParsePubKey(bs) 39 + if err != nil { 40 + return nil, err 41 + } 42 + addr := crypto.PubkeyToAddress(*pubkey.ToECDSA()) 43 + return &pub{addr}, nil 44 + } 45 + 37 46 func FromPoints(x, y *big.Int) (Pub, error) { 38 47 key := ecdsa.PublicKey{Curve: secp256k1.S256(), X: x, Y: y} 39 48 return FromPublicKey(&key)
+15 -2
pkg/media/media.go
··· 42 42 httpPipesMutex sync.Mutex 43 43 newSegmentSubs []chan *NewSegmentNotification 44 44 newSegmentSubsMutex sync.RWMutex 45 + model model.Model 45 46 } 46 47 47 48 type NewSegmentNotification struct { ··· 55 56 return SelfTest(ctx) 56 57 } 57 58 58 - func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer, rep replication.Replicator) (*MediaManager, error) { 59 + func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer, rep replication.Replicator, mod model.Model) (*MediaManager, error) { 59 60 gst.Init(nil) 60 61 err := SelfTest(ctx) 61 62 if err != nil { ··· 67 68 replicator: rep, 68 69 hlsRunning: map[string]*M3U8{}, 69 70 httpPipes: map[string]io.Writer{}, 71 + model: mod, 70 72 }, nil 71 73 } 72 74 ··· 330 332 if err != nil { 331 333 return err 332 334 } 335 + var repo *model.Repo 336 + if mm.model != nil { 337 + repo, err = mm.model.GetRepoBySigningKey(pub.String()) 338 + if err != nil { 339 + return err 340 + } 341 + } 333 342 found := false 334 343 for _, a := range mm.cli.AllowedStreams { 335 - if a.Equals(pub) { 344 + if a == pub.String() { 345 + found = true 346 + break 347 + } 348 + if repo != nil && repo.DID == a { 336 349 found = true 337 350 break 338 351 }
+1 -12
pkg/media/media_signer.go
··· 65 65 } 66 66 67 67 func (ms *MediaSigner) SignMP4(ctx context.Context, input io.ReadSeeker, start int64) ([]byte, error) { 68 - ident, err := ms.Model.GetIdentity(ms.Pub.String()) 69 - if err != nil { 70 - return nil, err 71 - } 72 - if ident.Handle == "" { 73 - return nil, fmt.Errorf("no handle set for streamer %s", ms.Pub.String()) 74 - } 75 - creator := []string{ident.Handle} 76 - if ident.DID != "" { 77 - creator = append(creator, ident.DID) 78 - } 79 68 title := "livestream" 80 69 mani := obj{ 81 70 "title": fmt.Sprintf("Livestream Segment at %s", aqtime.FromMillis(start)), ··· 95 84 "@context": obj{ 96 85 "dc": "http://purl.org/dc/elements/1.1/", 97 86 }, 98 - "dc:creator": creator, 87 + "dc:creator": ms.StreamerName, 99 88 "dc:title": []string{title}, 100 89 "dc:date": []string{aqtime.FromMillis(start).String()}, 101 90 },
+3 -4
pkg/media/media_test.go
··· 12 12 "aquareum.tv/aquareum/pkg/crypto/aqpub" 13 13 "aquareum.tv/aquareum/pkg/crypto/signers/eip712/eip712test" 14 14 _ "aquareum.tv/aquareum/pkg/media/mediatesting" 15 - "aquareum.tv/aquareum/pkg/model" 16 15 "aquareum.tv/aquareum/pkg/replication/boring" 17 16 "git.aquareum.tv/aquareum-tv/c2pa-go/pkg/c2pa" 18 17 "github.com/stretchr/testify/require" ··· 33 32 } 34 33 cli := ct.CLI(t, &config.CLI{ 35 34 TAURL: "http://timestamp.digicert.com", 36 - AllowedStreams: []aqpub.Pub{pub}, 35 + AllowedStreams: []string{pub.String()}, 37 36 }) 38 - mm, err := MakeMediaManager(context.Background(), cli, signer, &boring.BoringReplicator{}) 37 + mm, err := MakeMediaManager(context.Background(), cli, signer, &boring.BoringReplicator{}, nil) 39 38 require.NoError(t, err) 40 - ms, err := MakeMediaSigner(context.Background(), cli, "test-person", signer, &model.DBModel{}) 39 + ms, err := MakeMediaSigner(context.Background(), cli, "test-person", signer, nil) 41 40 return mm, ms 42 41 } 43 42
+1 -1
pkg/model/model.go
··· 39 39 40 40 GetRepo(did string) (*Repo, error) 41 41 GetRepoByHandle(handle string) (*Repo, error) 42 - GetRepoByAquareumKey(aquareumKey string) (*Repo, error) 42 + GetRepoBySigningKey(signingKey string) (*Repo, error) 43 43 UpdateRepo(repo *Repo) error 44 44 45 45 GetLiveUsers() ([]Segment, error)
+8 -8
pkg/model/repo.go
··· 7 7 ) 8 8 9 9 type Repo struct { 10 - DID string `gorm:"primaryKey;column:did" json:"did"` 11 - Handle string `gorm:"index" json:"handle"` 12 - PDS string `json:"pds"` 13 - Version string `json:"version"` 14 - AquareumKey string `gorm:"index" json:"aquareumKey"` 15 - RootCID string `json:"rootCid"` 10 + DID string `gorm:"primaryKey;column:did" json:"did"` 11 + Handle string `gorm:"index" json:"handle"` 12 + PDS string `json:"pds"` 13 + Version string `json:"version"` 14 + SigningKey string `gorm:"index" json:"signingKey"` 15 + RootCID string `json:"rootCid"` 16 16 } 17 17 18 18 func (Repo) TableName() string { ··· 43 43 return &repoModel, nil 44 44 } 45 45 46 - func (m *DBModel) GetRepoByAquareumKey(aquareumKey string) (*Repo, error) { 46 + func (m *DBModel) GetRepoBySigningKey(signingKey string) (*Repo, error) { 47 47 var repoModel Repo 48 - res := m.DB.Where("aquareum_key = ?", aquareumKey).First(&repoModel) 48 + res := m.DB.Where("signing_key = ?", signingKey).First(&repoModel) 49 49 if errors.Is(res.Error, gorm.ErrRecordNotFound) { 50 50 return nil, nil 51 51 }
+1 -1
pkg/model/segment.go
··· 11 11 User string `json:"user" gorm:"index:latest_segments"` 12 12 StartTime time.Time `json:"startTime" gorm:"index:latest_segments"` 13 13 Title string `json:"title"` 14 - Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:User;references:AquareumKey"` 14 + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:User;references:SigningKey"` 15 15 } 16 16 17 17 func (m *DBModel) CreateSegment(seg *Segment) error {
+36
yarn.lock
··· 203 203 languageName: node 204 204 linkType: hard 205 205 206 + "@atproto/crypto@npm:^0.4.2": 207 + version: 0.4.2 208 + resolution: "@atproto/crypto@npm:0.4.2" 209 + dependencies: 210 + "@noble/curves": "npm:^1.1.0" 211 + "@noble/hashes": "npm:^1.3.1" 212 + uint8arrays: "npm:3.0.0" 213 + checksum: 10/2042828555d701c1f19b860502590357356109886c9f1117117922658a521e40161c25aa215853f49f260eda7f82a5b6858b7e260c2e05acbfcb2d9f52dd7bd8 214 + languageName: node 215 + linkType: hard 216 + 206 217 "@atproto/did@npm:0.1.3": 207 218 version: 0.1.3 208 219 resolution: "@atproto/did@npm:0.1.3" ··· 5555 5566 languageName: node 5556 5567 linkType: hard 5557 5568 5569 + "@noble/curves@npm:^1.1.0": 5570 + version: 1.7.0 5571 + resolution: "@noble/curves@npm:1.7.0" 5572 + dependencies: 5573 + "@noble/hashes": "npm:1.6.0" 5574 + checksum: 10/2a11ef4895907d0b241bd3b72f9e6ebe56f0e705949bfd5efe003f25233549f620d287550df2d24ad56a1f953b82ec5f7cf4bd7cb78b1b2e76eb6dd516d44cf8 5575 + languageName: node 5576 + linkType: hard 5577 + 5558 5578 "@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.4.0": 5559 5579 version: 1.4.0 5560 5580 resolution: "@noble/hashes@npm:1.4.0" ··· 5566 5586 version: 1.5.0 5567 5587 resolution: "@noble/hashes@npm:1.5.0" 5568 5588 checksum: 10/da7fc7af52af7afcf59810a7eea6155075464ff462ffda2572dc6d57d53e2669b1ea2ec774e814f6273f1697e567f28d36823776c9bf7068cba2a2855140f26e 5589 + languageName: node 5590 + linkType: hard 5591 + 5592 + "@noble/hashes@npm:1.6.0": 5593 + version: 1.6.0 5594 + resolution: "@noble/hashes@npm:1.6.0" 5595 + checksum: 10/b44b043b02adbecd33596adeed97d9f9864c24a2410f7ac3b847986c2ecf1f6f0df76024b3f1b14d6ea954932960d88898fe551fb9d39844a8b870e9f9044ea1 5569 5596 languageName: node 5570 5597 linkType: hard 5571 5598 ··· 11282 11309 dependencies: 11283 11310 "@aquareum/atproto-oauth-client-react-native": "npm:^0.0.1" 11284 11311 "@atproto-labs/pipe": "npm:^0.1.0" 11312 + "@atproto/crypto": "npm:^0.4.2" 11285 11313 "@atproto/jwk-jose": "npm:^0.1.2" 11286 11314 "@atproto/oauth-client": "npm:^0.3.1" 11287 11315 "@babel/core": "npm:^7.26.0" ··· 11333 11361 expo-web-browser: "npm:^14.0.1" 11334 11362 hls.js: "npm:^1.5.17" 11335 11363 jose: "npm:^5.9.6" 11364 + multiformats: "npm:^13.3.1" 11336 11365 react: "npm:18.3.1" 11337 11366 react-dom: "npm:18.3.1" 11338 11367 react-native: "npm:0.76.2" ··· 21219 21248 bin: 21220 21249 multicast-dns: cli.js 21221 21250 checksum: 10/e9add8035fb7049ccbc87b1b069f05bb3b31e04fe057bf7d0116739d81295165afc2568291a4a962bee01a5074e475996816eed0f50c8110d652af5abb74f95a 21251 + languageName: node 21252 + linkType: hard 21253 + 21254 + "multiformats@npm:^13.3.1": 21255 + version: 13.3.1 21256 + resolution: "multiformats@npm:13.3.1" 21257 + checksum: 10/2e529613d457590dffe212a658546f313c7c7296d240d952d2baee7ce0abb227116d784f05cf4d238ef0db7d72ad2c3d04ea3c6b9bfd20db805a092024ce8d7e 21222 21258 languageName: node 21223 21259 linkType: hard 21224 21260