diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b877a10 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +POSTGRES_DB=screenshare +POSTGRES_USER=screenshare +POSTGRES_PASSWORD=change-me + +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=replace-with-a-long-random-secret +LIVEKIT_PUBLIC_URL=ws://localhost:7880 diff --git a/.gitignore b/.gitignore index b23ea64..6a7ce02 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env.* !.env.example !.env.*.example +apps/ScreenShareApi/src/main/resources/application.local.yaml # Java / Kotlin / Gradle .gradle/ diff --git a/apps/ScreenShareApi/Dockerfile b/apps/ScreenShareApi/Dockerfile new file mode 100644 index 0000000..a1f0f55 --- /dev/null +++ b/apps/ScreenShareApi/Dockerfile @@ -0,0 +1,18 @@ +FROM eclipse-temurin:21-jdk AS build +WORKDIR /app + +COPY gradlew gradlew +COPY gradle gradle +COPY build.gradle.kts settings.gradle.kts gradle.properties ./ +RUN chmod +x gradlew +RUN ./gradlew dependencies --no-daemon + +COPY src src +RUN ./gradlew installDist --no-daemon + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /app/build/install/ScreenShareApi ./ + +EXPOSE 8080 +ENTRYPOINT ["./bin/ScreenShareApi"] diff --git a/apps/ScreenShareApi/build.gradle.kts b/apps/ScreenShareApi/build.gradle.kts index a416935..63eea29 100644 --- a/apps/ScreenShareApi/build.gradle.kts +++ b/apps/ScreenShareApi/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) alias(ktorLibs.plugins.ktor) } @@ -17,6 +18,9 @@ dependencies { implementation(ktorLibs.server.config.yaml) implementation(ktorLibs.server.core) implementation(ktorLibs.server.netty) + implementation("io.ktor:ktor-server-content-negotiation-jvm:3.5.0") + implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:3.5.0") + implementation("io.ktor:ktor-server-cors-jvm:3.5.0") implementation(libs.logback.classic) testImplementation(kotlin("test")) diff --git a/apps/ScreenShareApi/gradle/libs.versions.toml b/apps/ScreenShareApi/gradle/libs.versions.toml index 0cb5f00..cda206b 100644 --- a/apps/ScreenShareApi/gradle/libs.versions.toml +++ b/apps/ScreenShareApi/gradle/libs.versions.toml @@ -8,3 +8,4 @@ logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "lo [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/apps/ScreenShareApi/src/main/kotlin/Application.kt b/apps/ScreenShareApi/src/main/kotlin/Application.kt new file mode 100644 index 0000000..376c6bc --- /dev/null +++ b/apps/ScreenShareApi/src/main/kotlin/Application.kt @@ -0,0 +1,11 @@ +package top.OrangeRoll + +import configureRouting +import io.ktor.server.application.Application + +/** + * Ktor's YAML module entry point. + */ +fun Application.module() { + configureRouting() +} diff --git a/apps/ScreenShareApi/src/main/kotlin/Routing.kt b/apps/ScreenShareApi/src/main/kotlin/Routing.kt index 5664a17..a898376 100644 --- a/apps/ScreenShareApi/src/main/kotlin/Routing.kt +++ b/apps/ScreenShareApi/src/main/kotlin/Routing.kt @@ -1,13 +1,205 @@ -package top.OrangeRoll - +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.request.receive import io.ktor.server.response.* import io.ktor.server.routing.* +import io.ktor.serialization.kotlinx.json.json +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.util.Base64 +import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json fun Application.configureRouting() { + install(ContentNegotiation) { + json() + } + install(CORS) { + val configuredOrigins = this@configureRouting.environment.config + .propertyOrNull("cors.allowedOrigins") + ?.getString() + ?.split(",") + ?.map(String::trim) + ?.filter(String::isNotEmpty) + .orEmpty() + + configuredOrigins.ifEmpty { listOf("http://localhost:5173") }.forEach { origin -> + val host = origin.removePrefix("http://").removePrefix("https://") + allowHost( + host = host, + schemes = listOf(if (origin.startsWith("https://")) "https" else "http"), + ) + } + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Get) + allowHeader(HttpHeaders.ContentType) + } + routing { - get("/") { - call.respondText("Hello, World!") + get("/health") { + call.respond(HealthResponse(status = "ok")) + } + + post("/api/rooms/{roomId}/token") { + val roomName = call.parameters["roomId"]?.trim().orEmpty() + if (!ROOM_NAME.matches(roomName)) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("roomId must contain 3-64 letters, numbers, underscores, or hyphens"), + ) + return@post + } + + val request = call.receive() + val role = runCatching { RoomRole.valueOf(request.role.lowercase()) }.getOrNull() + if (role == null) { + call.respond(HttpStatusCode.BadRequest, ErrorResponse("role must be host, publisher, or viewer")) + return@post + } + + val participantName = request.participantName.trim() + if (participantName.length !in 1..64) { + call.respond(HttpStatusCode.BadRequest, ErrorResponse("participantName must be 1-64 characters")) + return@post + } + + val apiKey = environment.config.propertyOrNull("livekit.apiKey")?.getString() + ?: System.getenv("LIVEKIT_API_KEY") + val apiSecret = environment.config.propertyOrNull("livekit.apiSecret")?.getString() + ?: System.getenv("LIVEKIT_API_SECRET") + val publicUrl = environment.config.propertyOrNull("livekit.publicUrl")?.getString() + ?: System.getenv("LIVEKIT_PUBLIC_URL") + + if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank() || publicUrl.isNullOrBlank()) { + application.log.error("LiveKit environment variables are not configured") + call.respond(HttpStatusCode.ServiceUnavailable, ErrorResponse("Video service is not configured")) + return@post + } + + val identity = "dev-${UUID.randomUUID()}" + val token = LiveKitTokenIssuer(apiKey, apiSecret).issue( + roomName = roomName, + identity = identity, + participantName = participantName, + role = role, + ) + + call.respond( + TokenResponse( + roomName = roomName, + participantIdentity = identity, + participantName = participantName, + livekitUrl = publicUrl, + token = token, + role = role.name, + ), + ) } } -} \ No newline at end of file +} + +private val ROOM_NAME = Regex("[A-Za-z0-9_-]{3,64}") + +@Serializable +data class HealthResponse(val status: String) + +@Serializable +data class TokenRequest( + val participantName: String, + val role: String = "viewer", +) + +@Serializable +data class TokenResponse( + val roomName: String, + val participantIdentity: String, + val participantName: String, + val livekitUrl: String, + val token: String, + val role: String, +) + +@Serializable +data class ErrorResponse(val message: String) + +private enum class RoomRole { + host, + publisher, + viewer, +} + +/** + * Creates a standard LiveKit HS256 access token. The API secret remains exclusively on the server. + * Replace the development role supplied by the client with application JWT authorization before production. + */ +private class LiveKitTokenIssuer( + private val apiKey: String, + private val apiSecret: String, +) { + fun issue( + roomName: String, + identity: String, + participantName: String, + role: RoomRole, + ): String { + val now = Instant.now().epochSecond + val canPublish = role == RoomRole.host || role == RoomRole.publisher + val payload = Json.encodeToString( + LiveKitClaims.serializer(), + LiveKitClaims( + iss = apiKey, + sub = identity, + name = participantName, + nbf = now, + exp = now + TOKEN_TTL_SECONDS, + video = VideoGrant( + roomJoin = true, + room = roomName, + canPublish = canPublish, + canSubscribe = true, + canPublishData = canPublish, + ), + ), + ) + val header = base64Url("""{"alg":"HS256","typ":"JWT"}""") + val body = base64Url(payload) + val unsignedToken = "$header.$body" + val signature = Mac.getInstance("HmacSHA256") + .apply { init(SecretKeySpec(apiSecret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) } + .doFinal(unsignedToken.toByteArray(StandardCharsets.UTF_8)) + return "$unsignedToken.${Base64.getUrlEncoder().withoutPadding().encodeToString(signature)}" + } + + private fun base64Url(value: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(StandardCharsets.UTF_8)) + + private companion object { + const val TOKEN_TTL_SECONDS = 10 * 60L + } +} + +@Serializable +private data class LiveKitClaims( + val iss: String, + val sub: String, + val name: String, + val nbf: Long, + val exp: Long, + val video: VideoGrant, +) + +@Serializable +private data class VideoGrant( + val roomJoin: Boolean, + val room: String, + val canPublish: Boolean, + val canSubscribe: Boolean, + val canPublishData: Boolean, +) diff --git a/apps/ScreenShareApi/src/main/resources/application.local.example.yaml b/apps/ScreenShareApi/src/main/resources/application.local.example.yaml new file mode 100644 index 0000000..ea51477 --- /dev/null +++ b/apps/ScreenShareApi/src/main/resources/application.local.example.yaml @@ -0,0 +1,12 @@ +ktor: + deployment: + port: 8080 + application: + modules: + - top.OrangeRoll.ApplicationKt.module + +# Local development only. Keep this file out of Git. +livekit: + apiKey: devkey + apiSecret: replace-with-a-long-random-secret + publicUrl: ws://localhost:7880 diff --git a/apps/ScreenShareApi/src/main/resources/application.yaml b/apps/ScreenShareApi/src/main/resources/application.yaml index 9c90124..8bb3285 100644 --- a/apps/ScreenShareApi/src/main/resources/application.yaml +++ b/apps/ScreenShareApi/src/main/resources/application.yaml @@ -1,6 +1,6 @@ ktor: deployment: - port: 8080 + port: ${PORT:8080} application: modules: - - top.OrangeRoll.RoutingKt.configureRouting + - top.OrangeRoll.ApplicationKt.module diff --git a/apps/ScreenShareApi/src/test/kotlin/ServerTest.kt b/apps/ScreenShareApi/src/test/kotlin/ServerTest.kt index ecef52d..eff2d02 100644 --- a/apps/ScreenShareApi/src/test/kotlin/ServerTest.kt +++ b/apps/ScreenShareApi/src/test/kotlin/ServerTest.kt @@ -3,16 +3,18 @@ package top.OrangeRoll import io.ktor.client.request.get import io.ktor.http.HttpStatusCode import io.ktor.server.testing.testApplication +import configureRouting import kotlin.test.* class ServerTest { @Test - fun `test root endpoint`() = testApplication { - // loads default configuration - configure() - // verify server root returns 200 - assertEquals(HttpStatusCode.OK, client.get("/").status) + fun `health endpoint returns ok`() = testApplication { + application { + configureRouting() + } + + assertEquals(HttpStatusCode.OK, client.get("/health").status) } } diff --git a/apps/ScreenShareWeb/Dockerfile b/apps/ScreenShareWeb/Dockerfile new file mode 100644 index 0000000..8485797 --- /dev/null +++ b/apps/ScreenShareWeb/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . + +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/apps/ScreenShareWeb/package-lock.json b/apps/ScreenShareWeb/package-lock.json index d219333..b1c0529 100644 --- a/apps/ScreenShareWeb/package-lock.json +++ b/apps/ScreenShareWeb/package-lock.json @@ -8,6 +8,7 @@ "name": "screenshareweb", "version": "0.0.0", "dependencies": { + "livekit-client": "^2.17.0", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -266,6 +267,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -510,6 +517,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.50.4", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", + "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -613,9 +635,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -633,9 +652,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -653,9 +669,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -673,9 +686,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -693,9 +703,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -713,9 +720,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -783,6 +787,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", + "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1513,6 +1524,15 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1721,6 +1741,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1942,9 +1971,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1966,9 +1992,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1990,9 +2013,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2014,9 +2034,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2072,6 +2089,26 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/livekit-client": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz", + "integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.50.4", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.6" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2088,6 +2125,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2350,12 +2400,37 @@ "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2429,6 +2504,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2442,6 +2523,15 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -2606,6 +2696,19 @@ } } }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/apps/ScreenShareWeb/package.json b/apps/ScreenShareWeb/package.json index b09cfac..c07427e 100644 --- a/apps/ScreenShareWeb/package.json +++ b/apps/ScreenShareWeb/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "livekit-client": "^2.17.0", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/apps/ScreenShareWeb/src/App.css b/apps/ScreenShareWeb/src/App.css index f90339d..59eae02 100644 --- a/apps/ScreenShareWeb/src/App.css +++ b/apps/ScreenShareWeb/src/App.css @@ -1,184 +1,201 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } +.app-shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 56px 0; } -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } +header { + margin-bottom: 32px; } -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } +.eyebrow { + color: #6d5dfc; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; } -#next-steps { - display: flex; - border-top: 1px solid var(--border); +h1 { + margin: 8px 0; + color: #202135; + font-size: clamp(2rem, 5vw, 3.5rem); +} + +h2 { + color: #292a40; + font-size: 1.1rem; +} + +.subtitle, .development-note { + color: #666980; +} + +.join-panel { + display: grid; + grid-template-columns: 1.1fr 1fr 1fr auto; + gap: 14px; + align-items: end; + padding: 20px; + border: 1px solid #e0e1ee; + border-radius: 16px; + background: #fff; + box-shadow: 0 8px 30px rgb(30 33 80 / 7%); +} + +label { + display: grid; + gap: 7px; + color: #4a4c62; + font-size: 0.85rem; + font-weight: 600; text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +input, select, button { + box-sizing: border-box; + min-height: 42px; + border-radius: 9px; + font: inherit; } -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; +input, select { width: 100%; + padding: 0 12px; + border: 1px solid #cacbdd; + background: #fff; + color: #292a40; +} - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; +button { + padding: 0 16px; + border: 0; + cursor: pointer; + font-weight: 700; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.primary { + background: #6254e8; + color: #fff; +} + +.secondary { + border: 1px solid #c9cae0; + background: #fff; + color: #383a52; +} + +.error { + margin: 16px 0 0; + padding: 12px 14px; + border-radius: 8px; + background: #fff0f0; + color: #a12828; + text-align: left; +} + +.status-bar { + display: flex; + gap: 16px; + align-items: center; + margin: 24px 0; + color: #585b73; +} + +.status { + padding: 5px 10px; + border-radius: 999px; + background: #edeef5; + font-size: 0.85rem; +} + +.status.online { + background: #def7e9; + color: #167549; +} + +.share-button { + margin-left: auto; + background: #202135; + color: #fff; +} + +.hint { + color: #8a6a18; + font-size: 0.86rem; +} + +.screens { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.screen-section { + min-width: 0; + text-align: left; +} + +.video-stack { + display: grid; + gap: 12px; + min-height: 280px; + padding: 12px; + border: 1px solid #e0e1ee; + border-radius: 14px; + background: #f6f6fb; +} + +.empty { + align-self: center; + color: #797c92; + text-align: center; +} + +.screen-frame { + margin: 0; + overflow: hidden; + border-radius: 9px; + background: #181a29; +} + +.screen-frame figcaption { + padding: 8px 10px; + color: #e8e9f4; + font-size: 0.82rem; +} + +.media-slot video { + display: block; + width: 100%; + max-height: 420px; + background: #10111b; +} + +.development-note { + margin: 28px auto 0; + max-width: 780px; + font-size: 0.85rem; +} + +@media (max-width: 760px) { + .app-shell { + padding: 32px 0; } - &::before { - left: 0; - border-left-color: var(--border); + .join-panel, .screens { + grid-template-columns: 1fr; } - &::after { - right: 0; - border-right-color: var(--border); + + .status-bar { + flex-wrap: wrap; + } + + .share-button { + margin-left: 0; } } diff --git a/apps/ScreenShareWeb/src/App.tsx b/apps/ScreenShareWeb/src/App.tsx index a66b5ef..1e613a1 100644 --- a/apps/ScreenShareWeb/src/App.tsx +++ b/apps/ScreenShareWeb/src/App.tsx @@ -1,121 +1,259 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' +import { useEffect, useRef, useState } from 'react' +import { Room, RoomEvent, Track } from 'livekit-client' import './App.css' -function App() { - const [count, setCount] = useState(0) +type Role = 'host' | 'publisher' | 'viewer' + +type TokenResponse = { + roomName: string + participantIdentity: string + participantName: string + livekitUrl: string + token: string + role: Role +} + +type ScreenFeed = { + track: Track + label: string +} + +function ScreenTrack({ track, label }: ScreenFeed) { + const mediaContainerRef = useRef(null) + + useEffect(() => { + const mediaElement = track.attach() + mediaElement.autoplay = true + if (mediaElement instanceof HTMLVideoElement) { + // Local preview must be muted for browsers to allow autoplay. + mediaElement.muted = true + mediaElement.playsInline = true + void mediaElement.play().catch(() => { + // The user can still start playback manually if their browser blocks it. + }) + } + mediaContainerRef.current?.append(mediaElement) + + return () => { + mediaElement.remove() + } + }, [track]) return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- +
+
{label}
+
+
+ ) +} + +function App() { + const roomRef = useRef(null) + const [roomName, setRoomName] = useState('demo-room') + const [participantName, setParticipantName] = useState('') + const [role, setRole] = useState('publisher') + const [connectionState, setConnectionState] = useState<'idle' | 'connecting' | 'connected'>('idle') + const [isSharing, setIsSharing] = useState(false) + const [remoteCount, setRemoteCount] = useState(0) + const [localScreen, setLocalScreen] = useState(null) + const [remoteScreens, setRemoteScreens] = useState([]) + const [error, setError] = useState('') + + useEffect(() => { + return () => { + roomRef.current?.disconnect() + } + }, []) + + function refreshRemoteCount(room: Room) { + setRemoteCount(room.remoteParticipants.size) + } + + async function joinRoom() { + const normalizedRoom = roomName.trim() + const normalizedName = participantName.trim() + + if (!/^[A-Za-z0-9_-]{3,64}$/.test(normalizedRoom)) { + setError('房间号只能包含 3–64 个字母、数字、下划线或连字符。') + return + } + if (!normalizedName) { + setError('请输入显示名称。') + return + } + + setError('') + setConnectionState('connecting') + + try { + const response = await fetch(`/api/rooms/${encodeURIComponent(normalizedRoom)}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ participantName: normalizedName, role }), + }) + const payload = (await response.json()) as TokenResponse | { message: string } + + if (!response.ok || !('token' in payload)) { + throw new Error('message' in payload ? payload.message : '无法获取房间凭证。') + } + + roomRef.current?.disconnect() + setLocalScreen(null) + setRemoteScreens([]) + + const room = new Room() + room.on(RoomEvent.ParticipantConnected, () => refreshRemoteCount(room)) + room.on(RoomEvent.ParticipantDisconnected, () => refreshRemoteCount(room)) + room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { + if (track.kind === Track.Kind.Video && publication.source === Track.Source.ScreenShare) { + setRemoteScreens((screens) => [ + ...screens.filter((screen) => screen.track.sid !== track.sid), + { track, label: `${participant.name || participant.identity} 的屏幕` }, + ]) + } + }) + room.on(RoomEvent.TrackUnsubscribed, (track) => { + setRemoteScreens((screens) => screens.filter((screen) => screen.track !== track)) + }) + room.on(RoomEvent.LocalTrackPublished, (publication) => { + if (publication.source === Track.Source.ScreenShare && publication.track) { + setLocalScreen({ track: publication.track, label: '你正在共享的屏幕' }) + setIsSharing(true) + } + }) + room.on(RoomEvent.LocalTrackUnpublished, (publication) => { + if (publication.source === Track.Source.ScreenShare) { + setLocalScreen(null) + setIsSharing(false) + } + }) + room.on(RoomEvent.Disconnected, () => { + setConnectionState('idle') + setIsSharing(false) + setRemoteCount(0) + setLocalScreen(null) + setRemoteScreens([]) + }) + + await room.connect(payload.livekitUrl, payload.token) + roomRef.current = room + refreshRemoteCount(room) + setConnectionState('connected') + } catch (cause) { + setConnectionState('idle') + setError(cause instanceof Error ? cause.message : '连接房间时发生未知错误。') + } + } + + async function toggleScreenShare() { + const room = roomRef.current + if (!room) { + return + } + + setError('') + try { + await room.localParticipant.setScreenShareEnabled(!isSharing) + } catch (cause) { + setError(cause instanceof Error ? cause.message : '无法切换屏幕共享。') + } + } + + function leaveRoom() { + roomRef.current?.disconnect() + roomRef.current = null + setConnectionState('idle') + setIsSharing(false) + setRemoteCount(0) + setLocalScreen(null) + setRemoteScreens([]) + } + + const connected = connectionState === 'connected' + const canShare = role === 'host' || role === 'publisher' + + return ( +
+
+

LIVEKIT · 开发环境

+

屏幕共享房间

+

浏览器直接连接 LiveKit,Ktor 仅签发短期访问凭证。

+
+ +
+ + + + + {!connected ? ( + + ) : ( + + )}
-
+ {error &&

{error}

} -
-
- -

Documentation

-

Your questions, answered

- +
+ {connected ? '已连接' : '未连接'} + 其他参与者:{remoteCount} + {connected && ( + + )} + {connected && !canShare && Viewer 没有发布权限} +
+ +
+
+

我的屏幕

+
+ {localScreen ? :

尚未共享屏幕

} +
-
- -

Connect with us

-

Join the Vite community

- +
+

远端屏幕

+
+ {remoteScreens.length === 0 + ?

等待其他参与者共享屏幕

+ : remoteScreens.map((screen) => ( + + ))} +
-
-
- +

+ 当前角色由客户端请求,仅用于本地验证。生产环境必须由应用 JWT 与房间成员权限决定角色。 +

+
) } diff --git a/apps/ScreenShareWeb/src/index.css b/apps/ScreenShareWeb/src/index.css index 5fb3313..467341c 100644 --- a/apps/ScreenShareWeb/src/index.css +++ b/apps/ScreenShareWeb/src/index.css @@ -1,111 +1,13 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #292a40; + background: #f4f4fa; font-synthesis: none; text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; } body { + min-width: 320px; + min-height: 100vh; margin: 0; } - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} diff --git a/apps/ScreenShareWeb/vite.config.ts b/apps/ScreenShareWeb/vite.config.ts index 8b0f57b..1e11ed7 100644 --- a/apps/ScreenShareWeb/vite.config.ts +++ b/apps/ScreenShareWeb/vite.config.ts @@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { + target: process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:8080', + changeOrigin: true, + }, + '/health': { + target: process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, }) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0eda714 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + livekit: + image: livekit/livekit-server:v1.9.10 + command: --config /etc/livekit.yaml + volumes: + - ./infra/livekit/livekit.yaml:/etc/livekit.yaml:ro + environment: + LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" + ports: + - "7880:7880" + - "7881:7881" + - "50000-50100:50000-50100/udp" + + api: + build: + context: ./apps/ScreenShareApi + env_file: .env + environment: + PORT: 8080 + LIVEKIT_API_KEY: ${LIVEKIT_API_KEY} + LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET} + LIVEKIT_PUBLIC_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880} + depends_on: + - livekit + ports: + - "8080:8080" + + web: + build: + context: ./apps/ScreenShareWeb + environment: + VITE_API_PROXY_TARGET: http://api:8080 + depends_on: + - api + ports: + - "5173:5173" diff --git a/infra/livekit/livekit.yaml b/infra/livekit/livekit.yaml new file mode 100644 index 0000000..289710f --- /dev/null +++ b/infra/livekit/livekit.yaml @@ -0,0 +1,8 @@ +port: 7880 + +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 50100 + +# Credentials are supplied at runtime through LIVEKIT_KEYS. Do not put them in this file.