commit laplace repo

This commit is contained in:
2025-06-24 17:11:13 +01:00
commit d421a5b009
20 changed files with 1735 additions and 0 deletions

3
laplace/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.idea
.DS_Store
Dockerfile

2
laplace/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.idea
.DS_Store

15
laplace/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM golang:latest as builder
LABEL maintainer="Adam Jordan <adamyordan@gmail.com>"
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o laplace .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /build/laplace .
COPY files files
EXPOSE 443
CMD ["./laplace"]

21
laplace/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Adam Jordan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

102
laplace/README.md Normal file
View File

@@ -0,0 +1,102 @@
# Laplace
Laplace is an open-source project to enable screen sharing directly via browser.
Made possible using WebRTC for low latency peer-to-peer connections, and WebSocket implemented in golang for WebRTC signaling.
> Demo video: https://youtu.be/E8cUaPrAlzE
[![Laplace for gaming](./doc/laplace-for-gaming.png)](https://youtu.be/E8cUaPrAlzE)
## Try Demo
For demo, you can visit https://laplace.madeby.monster/
![Website screenshot](./doc/screenshot-website.png)
## Motivation
There are already possible solutions to share your computer screen, e.g. TeamViewer.
But most of them require installations of software or plugins.
What Laplace provides is a simple solution to this problem.
For users wanting to share their screen, all they need to do is to open a website page with their browsers, clicking some buttons, then share some session ID with their peers.
No installation or registration required.
#### Solving the latency problem
This project also serves as a proof-of-concept (PoC) for screen sharing capability directly in browsers based on WebRTC.
Using WebRTC, real-time communication is made possible through peer-to-peer connections.
This proves to be very useful in solving one of the biggest problems is screen streaming: **Latency**.
The latency represents how long the delay is from the source to transmit to the remote client.
If you notice, this latency problem is usually highlighted by game streaming services, since gameplay relies heavily on the interactivity of inputs and outputs.
#### Low server cost
This solution also solves the server cost problem, since the expensive operations (encoding and transmission) are done on client browsers.
The server is only needed for serving frontends and for WebRTC signaling.
#### Possible Use Cases
- Game streaming from PC to mobile devices.
- Collaborative work where you need to share your screen with remote coworkers.
- Mirroring presentation slides and demonstrations.
## Installation
Build from source
```bash
$ git clone https://github.com/adamyordan/laplace.git
$ cd laplace && go build -o laplace main.go
$ ./laplace --help
```
OR, pull the pre-built docker image
```bash
$ docker pull adamyordan/laplace
$ docker run adamyordan/laplace ./laplace --help
```
## Program Execution
Executing this project basically serves an HTTP server that will host the frontend and the WebSocket implementation.
Note that you sometimes need to run HTTPs in order for browser to connect to websocket.
```bash
$ ./laplace --help
-addr string
Listen address (default "0.0.0.0:443")
-certFile string
TLS cert file (default "files/server.crt")
-keyFile string
TLS key file (default "files/server.key")
-tls
Use TLS (default true)
```
By default, you can run the executable without any argument to listen to TLS port 443.
A self-signed certificate files are provided to ease up development.
```bash
$ ./laplace
2020/03/25 01:01:10 Listening on TLS: 0.0.0.0:443
```
You can then open https://localhost:443/ to view Laplace page.
You may need to add certificate exceptions. In Chrome, you can type `thisisunsafe`.
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
## License
[MIT](https://choosealicense.com/licenses/mit/)

View File

@@ -0,0 +1,497 @@
package core
import (
"fmt"
"math/rand"
)
var (
left = [...]string{
"admiring",
"adoring",
"affectionate",
"agitated",
"amazing",
"angry",
"awesome",
"beautiful",
"blissful",
"bold",
"boring",
"brave",
"busy",
"charming",
"clever",
"cool",
"compassionate",
"competent",
"condescending",
"confident",
"cranky",
"crazy",
"dazzling",
"determined",
"distracted",
"dreamy",
"eager",
"ecstatic",
"elastic",
"elated",
"elegant",
"eloquent",
"epic",
"exciting",
"fervent",
"festive",
"flamboyant",
"focused",
"friendly",
"frosty",
"funny",
"gallant",
"gifted",
"goofy",
"gracious",
"great",
"happy",
"hardcore",
"heuristic",
"hopeful",
"hungry",
"infallible",
"inspiring",
"interesting",
"intelligent",
"jolly",
"jovial",
"keen",
"kind",
"laughing",
"loving",
"lucid",
"magical",
"mystifying",
"modest",
"musing",
"naughty",
"nervous",
"nice",
"nifty",
"nostalgic",
"objective",
"optimistic",
"peaceful",
"pedantic",
"pensive",
"practical",
"priceless",
"quirky",
"quizzical",
"recursing",
"relaxed",
"reverent",
"romantic",
"sad",
"serene",
"sharp",
"silly",
"sleepy",
"stoic",
"strange",
"stupefied",
"suspicious",
"sweet",
"tender",
"thirsty",
"trusting",
"unruffled",
"upbeat",
"vibrant",
"vigilant",
"vigorous",
"wizardly",
"wonderful",
"xenodochial",
"youthful",
"zealous",
"zen",
}
// Docker, starting from 0.7.x, generates names from notable scientists and hackers.
// Please, for any amazing man that you add to the list, consider adding an equally amazing woman to it, and vice versa.
right = [...]string{
"aardvark",
"abyssinian",
"affenpinscher",
"akbash",
"akita",
"albatross",
"alligator",
"alpaca",
"angelfish",
"ant",
"anteater",
"antelope",
"ape",
"armadillo",
"ass",
"avocet",
"axolotl",
"baboon",
"badger",
"balinese",
"bandicoot",
"barb",
"barnacle",
"barracuda",
"bat",
"beagle",
"bear",
"beaver",
"bee",
"beetle",
"binturong",
"bird",
"birman",
"bison",
"bloodhound",
"boar",
"bobcat",
"bombay",
"bongo",
"bonobo",
"booby",
"budgerigar",
"buffalo",
"bulldog",
"bullfrog",
"burmese",
"butterfly",
"caiman",
"camel",
"capybara",
"caracal",
"caribou",
"cassowary",
"cat",
"caterpillar",
"catfish",
"cattle",
"centipede",
"chameleon",
"chamois",
"cheetah",
"chicken",
"chihuahua",
"chimpanzee",
"chinchilla",
"chinook",
"chipmunk",
"chough",
"cichlid",
"clam",
"coati",
"cobra",
"cockroach",
"cod",
"collie",
"coral",
"cormorant",
"cougar",
"cow",
"coyote",
"crab",
"crane",
"crocodile",
"crow",
"curlew",
"cuscus",
"cuttlefish",
"dachshund",
"dalmatian",
"deer",
"dhole",
"dingo",
"dinosaur",
"discus",
"dodo",
"dog",
"dogfish",
"dolphin",
"donkey",
"dormouse",
"dotterel",
"dove",
"dragonfly",
"drever",
"duck",
"dugong",
"dunker",
"dunlin",
"eagle",
"earwig",
"echidna",
"eel",
"eland",
"elephant",
"elk",
"emu",
"falcon",
"ferret",
"finch",
"fish",
"flamingo",
"flounder",
"fly",
"fossa",
"fox",
"frigatebird",
"frog",
"galago",
"gar",
"gaur",
"gazelle",
"gecko",
"gerbil",
"gharial",
"gibbon",
"giraffe",
"gnat",
"gnu",
"goat",
"goldfinch",
"goldfish",
"goose",
"gopher",
"gorilla",
"goshawk",
"grasshopper",
"greyhound",
"grouse",
"guanaco",
"gull",
"guppy",
"hamster",
"hare",
"harrier",
"havanese",
"hawk",
"hedgehog",
"heron",
"herring",
"himalayan",
"hippopotamus",
"hornet",
"horse",
"human",
"hummingbird",
"hyena",
"ibis",
"iguana",
"impala",
"indri",
"insect",
"jackal",
"jaguar",
"javanese",
"jay",
"jellyfish",
"kakapo",
"kangaroo",
"kingfisher",
"kiwi",
"koala",
"kouprey",
"kudu",
"labradoodle",
"ladybird",
"lapwing",
"lark",
"lemming",
"lemur",
"leopard",
"liger",
"lion",
"lionfish",
"lizard",
"llama",
"lobster",
"locust",
"loris",
"louse",
"lynx",
"lyrebird",
"macaw",
"magpie",
"mallard",
"maltese",
"manatee",
"mandrill",
"markhor",
"marten",
"mastiff",
"mayfly",
"meerkat",
"millipede",
"mink",
"mole",
"molly",
"mongoose",
"mongrel",
"monkey",
"moorhen",
"moose",
"mosquito",
"moth",
"mouse",
"mule",
"narwhal",
"neanderthal",
"newfoundland",
"newt",
"nightingale",
"numbat",
"ocelot",
"octopus",
"okapi",
"olm",
"opossum",
"orangutan",
"oryx",
"ostrich",
"otter",
"owl",
"ox",
"oyster",
"pademelon",
"panther",
"parrot",
"partridge",
"peacock",
"peafowl",
"pekingese",
"pelican",
"penguin",
"persian",
"pheasant",
"pig",
"pigeon",
"pika",
"pike",
"piranha",
"platypus",
"pointer",
"pony",
"poodle",
"porcupine",
"porpoise",
"possum",
"prawn",
"puffin",
"pug",
"puma",
"quail",
"quelea",
"quetzal",
"quokka",
"quoll",
"rabbit",
"raccoon",
"ragdoll",
"rail",
"ram",
"rat",
"rattlesnake",
"raven",
"reindeer",
"rhinoceros",
"robin",
"rook",
"rottweiler",
"ruff",
"salamander",
"salmon",
"sandpiper",
"saola",
"sardine",
"scorpion",
"seahorse",
"seal",
"serval",
"shark",
"sheep",
"shrew",
"shrimp",
"siamese",
"siberian",
"skunk",
"sloth",
"snail",
"snake",
"snowshoe",
"somali",
"sparrow",
"spider",
"sponge",
"squid",
"squirrel",
"starfish",
"starling",
"stingray",
"stinkbug",
"stoat",
"stork",
"swallow",
"swan",
"tang",
"tapir",
"tarsier",
"termite",
"tetra",
"tiffany",
"tiger",
"toad",
"tortoise",
"toucan",
"tropicbird",
"trout",
"tuatara",
"turkey",
"turtle",
"uakari",
"uguisu",
"umbrellabird",
"viper",
"vulture",
"wallaby",
"walrus",
"warthog",
"wasp",
"weasel",
"whale",
"whippet",
"wildebeest",
"wolf",
"wolverine",
"wombat",
"woodcock",
"woodlouse",
"woodpecker",
"worm",
"wrasse",
"wren",
"yak",
"zebra",
"zebu",
"zonkey",
"zorse",
}
)
func GetRandomName(retry int) string {
name := fmt.Sprintf("%s_%s_%s", left[rand.Intn(len(left))], left[rand.Intn(len(left))], right[rand.Intn(len(right))])
if retry > 0 {
name = fmt.Sprintf("%s_%d", name, rand.Intn(10))
}
return name
}

74
laplace/core/room.go Normal file
View File

@@ -0,0 +1,74 @@
package core
import (
"fmt"
"github.com/gorilla/websocket"
)
type Room struct {
ID string
Sessions map[string]*StreamSession
CallerConn *websocket.Conn
}
type StreamSession struct {
ID string
Offer string
Answer string
CallerIceCandidates []string
CalleeIceCandidates []string
CallerConn *websocket.Conn
CalleeConn *websocket.Conn
}
var roomMap = make(map[string]*Room)
func GetRoom(id string) *Room {
return roomMap[id]
}
func NewRoom(callerConn *websocket.Conn) *Room {
room := Room{
ID: newRoomID(),
Sessions: make(map[string]*StreamSession),
CallerConn: callerConn,
}
roomMap[room.ID] = &room
return &room
}
func newRoomID() string {
id := GetRandomName(0)
for GetRoom(id) != nil {
id = GetRandomName(0)
}
return id
}
func RemoveRoom(id string) {
roomMap[id] = nil
}
func (room *Room) GetSession(id string) *StreamSession {
return room.Sessions[id]
}
func (room *Room) NewSession(calleeConn *websocket.Conn) *StreamSession {
session := StreamSession{
ID: room.newSessionID(),
CallerIceCandidates: []string{},
CalleeIceCandidates: []string{},
CallerConn: room.CallerConn,
CalleeConn: calleeConn,
}
room.Sessions[session.ID] = &session
return &session
}
func (room *Room) newSessionID() string {
id := fmt.Sprintf("%s$%s", room.ID, GetRandomName(0))
for GetRoom(id) != nil {
id = fmt.Sprintf("%s$%s", room.ID, GetRandomName(0))
}
return id
}

159
laplace/core/signal.go Normal file
View File

@@ -0,0 +1,159 @@
package core
import (
"github.com/gorilla/websocket"
"log"
"net/http"
"time"
)
type WSMessage struct {
SessionID string
Type string
Value string
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func sendHeartBeatWS(ticker *time.Ticker, conn *websocket.Conn, quit chan struct{}) {
for {
select {
case <- ticker.C:
_ = conn.WriteJSON(WSMessage{
Type: "beat",
})
case <- quit:
log.Println("heartbeat stopped")
return
}
}
}
func GetHttp() *http.ServeMux {
server := http.NewServeMux()
server.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("files/static"))))
server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "files/main.html")
})
server.HandleFunc("/ws_serve", func(writer http.ResponseWriter, request *http.Request) {
conn, _ := upgrader.Upgrade(writer, request, nil)
room := NewRoom(conn)
if err := conn.WriteJSON(WSMessage{
SessionID: "",
Type: "newRoom",
Value: room.ID,
}); err != nil {
log.Println("newSessionWriteJsonError.", err)
return
}
go func(r *Room) {
ticker := time.NewTicker(10 * time.Second)
quit := make(chan struct{})
defer func() {
ticker.Stop()
_ = room.CallerConn.Close()
close(quit)
RemoveRoom(r.ID)
for sID, s := range r.Sessions {
_ = s.CalleeConn.WriteJSON(WSMessage{
Type: "roomClosed",
SessionID: sID,
})
}
}()
go sendHeartBeatWS(ticker, conn, quit)
//noinspection ALL
defer room.CallerConn.Close()
for {
var msg WSMessage
if err := room.CallerConn.ReadJSON(&msg); err != nil {
log.Println("websocketError.", err)
return
}
//log.Println(msg)
s := room.GetSession(msg.SessionID)
if s == nil {
log.Println("session nil.", msg.SessionID)
}
if msg.Type == "addCallerIceCandidate" {
s.CallerIceCandidates = append(s.CallerIceCandidates, msg.Value)
} else if msg.Type == "gotOffer" {
s.Offer = msg.Value
}
if err := s.CalleeConn.WriteJSON(msg); err != nil {
log.Println("serveEchoWriteJsonError.", err)
}
}
}(room)
})
server.HandleFunc("/ws_connect", func(writer http.ResponseWriter, request *http.Request) {
conn, _ := upgrader.Upgrade(writer, request, nil)
ids, ok := request.URL.Query()["id"]
if !ok || ids[0] == "" {
return
}
room := GetRoom(ids[0])
if room == nil {
_ = conn.WriteJSON(WSMessage{
Type: "roomNotFound",
})
return
}
session := room.NewSession(conn)
if err := room.CallerConn.WriteJSON(WSMessage{
SessionID: session.ID,
Type: "newSession",
Value: session.ID,
}); err != nil {
log.Println("callerWriteJsonError.", err)
return
}
if err := conn.WriteJSON(WSMessage{
SessionID: session.ID,
Type: "newSession",
Value: session.ID,
}); err != nil {
log.Println("calleeWriteJsonError.", err)
return
}
go func(s *StreamSession) {
//noinspection ALL
defer s.CalleeConn.Close()
for {
var msg WSMessage
if err := conn.ReadJSON(&msg); err != nil {
log.Println("websocketError.", err)
return
}
//log.Println(msg)
if msg.SessionID == s.ID {
if msg.Type == "addCalleeIceCandidate" {
s.CalleeIceCandidates = append(s.CalleeIceCandidates, msg.Value)
} else if msg.Type == "gotAnswer" {
s.Answer = msg.Value
}
if err := s.CallerConn.WriteJSON(msg); err != nil {
log.Println("connectEchoWriteJsonError.", err)
}
}
}
}(session)
})
return server
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

167
laplace/files/main.html Normal file
View File

@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Laplace</title>
<link rel="stylesheet" href="/static/bootstrap.min.css">
<link rel="stylesheet" href="/static/main.css?v=0.0.5">
</head>
<body>
<nav class="navbar">
<a class="navbar-brand text-dark" href="/">Laplace</a>
<span class="navbar-brand header-room" id="room-text"></span>
</nav>
<div class="container">
<div class="panel" id="panel">
<p class="help-text">
<b>Laplace</b> is an open-source project to enable screen sharing directly via browser.
Made possible using WebRTC for low latency peer-to-peer connections,
and websocket implemented in golang for WebRTC signaling.
<a href="https://github.com/adamyordan/laplace">Read more</a>
</p>
<div class="separator"></div>
<h4>Start sharing your screen</h4>
<p class="help-text">
Click the button below to create a new streaming room,
where you can share your screen with your peers.
A RoomID will be generated, and you can share the RoomID to your peers.
</p>
<button type="button" class="btn btn-dark btn-block" id="btnStream">Start sharing</button>
<div class="separator"></div>
<h4>Join streaming room</h4>
<p class="help-text">
Enter a RoomID to join the streaming room.
You can get the RoomID from your peer that is doing a screen sharing.
</p>
<form id="joinForm">
<div class="form-group">
<input id="inputRoomID" type="text" class="form-control form-control-lg text-center" placeholder="Enter room ID" required>
</div>
<button type="submit" class="btn btn-dark btn-block" value="submit">Join</button>
</form>
</div>
</div>
<div class="container container-small" id="stream-serve-page-ui">
<div class="config-panel">
<div class="row">
<div class="col">
<h4>Screen sharing configuration</h4>
<p class="help-text">
For most people, using the default "balanced" preset is enough,
then click the "Start sharing" button to start.
For the curious, You may also choose configuration preset that will affect the performance and quality of the streaming.
For advance user, you can modify the options inside the textbox directly, please note that it may incur errors.
</p>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="form-group">
<label for=optionPreset>Preset</label>
<br>
<select name="optionPreset" id="inputOptionPreset" class="form-control"></select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for=displayMediaOption>DisplayMedia option</label>
<textarea name="displayMediaOption" id="inputDisplayMediaOption" cols="30" rows="5" class="form-control"></textarea>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for=RTPPeerConnection>RTPPeerConnection option</label>
<textarea name="RTPPeerConnectionOption" id="inputRTPPeerConnectionOption" cols="30" rows="5" class="form-control"></textarea>
</div>
</div>
</div>
<hr>
<button type="button" class="btn btn-dark btn-block-xs-only" id="btnStartStream">Start stream</button>
<span>&nbsp;</span>
<button type="button" class="btn btn-outline-dark btn-block-xs-only" id="btnStream" onclick="leaveRoom()">Cancel</button>
</div>
<br><br>
<p class="help-text">
<b>Some notes</b><br>
After clicking the "Start stream" button, if your device is supported, you may be asked to choose which screen you want to share.
<br>
As of now, mobile devices (Android & iOS) are usually not supported for screen sharing.
<br>
Sharing system audio is tested to be working only with latest Google Chrome running in Windows OS.
<br>
Remember to tick the "Share audio" checkbox.
<br>
In case the streaming does not work, reload the page, the WebRTC maybe unstable.
</p>
</div>
<div class="container-fluid" id="video-container">
<div id="video-wrapper">
<video id="mainVideo" autoplay playsinline controls>
</video>
</div>
</div>
<br><br>
<div class="container container-small" id="stream-page-ui">
<div class="row">
<div class="col-md-9">
<h4>How to Join this Room</h4>
<p class="help-text">
You can share the RoomID to your peers (it is on the top-right position on this page).
They can also join with the following link or QRCode:
</p>
<a href="#" id="join-link" target="_blank"></a>
<br><br>
</div>
<div class="col-md-3">
<div id="qrcode"></div>
</div>
</div>
<hr>
<div>
<h6>Status</h6>
<table class="table table-borderless table-sm table-meta">
<tr>
<td>Number of connections:</td>
<td id="statusNumConn">0</td>
</tr>
<tr>
<td>Connected peers:</td>
<td id="statusPeers"></td>
</tr>
<tr>
<td>Ping:</td>
<td id="statusPing">0 ms</td>
</tr>
</table>
<button type="button" class="btn btn-dark btn-block-xs-only" id="btnStream" onclick="leaveRoom()">Leave Room</button>
</div>
<hr>
<pre id="output"></pre>
</div>
<div class="container">
<hr>
<div class="text-center small" id="footer">
<span>Laplace Project by Adam Jordan.</span>
<a href="https://github.com/adamyordan/laplace">Source Code</a>
</div>
</div>
<script src="/static/qrcode.min.js"></script>
<script src="/static/main.js?v=0.0.5"></script>
</body>
</html>

9
laplace/files/server.crt Normal file
View File

@@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIBRjCBzQIJAO9zOPn7NhXdMAoGCCqGSM49BAMCMA0xCzAJBgNVBAYTAlNHMB4X
DTIwMDMyMjA3MTAwNFoXDTMwMDMyMDA3MTAwNFowDTELMAkGA1UEBhMCU0cwdjAQ
BgcqhkjOPQIBBgUrgQQAIgNiAARK0vXzgCXCkFXMhkNyW3bCPmVkpdmzhmKvh/5l
zVFXBX20bD5Nd/Ja6bmZ5tfpabAAJv9U9i+xh5zIMyENIu/WoYZqjbIfffYK2xkA
VoZLQraKM8yWCGJE7k7cw+cUYaEwCgYIKoZIzj0EAwIDaAAwZQIwUwmVA+sCyJ8d
tfxK/Yh7BMCduSLw1dPqyr2lF90b6OL6oetKa/xtTcKLPomgHEFuAjEA9Pv23QD6
xqwwedsfrxNA3Z8WDWA+rWOEmDcsxtrTSuW1t1HsCAqzFLxQn9mIoX+b
-----END CERTIFICATE-----

9
laplace/files/server.key Normal file
View File

@@ -0,0 +1,9 @@
-----BEGIN EC PARAMETERS-----
BgUrgQQAIg==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDDVijLZH3+6qNVMntFz5xTbqiyp8IBqqS1+TidkLS0ZVqX4u6WTRhst
1yP67FyQ9HCgBwYFK4EEACKhZANiAARK0vXzgCXCkFXMhkNyW3bCPmVkpdmzhmKv
h/5lzVFXBX20bD5Nd/Ja6bmZ5tfpabAAJv9U9i+xh5zIMyENIu/WoYZqjbIfffYK
2xkAVoZLQraKM8yWCGJE7k7cw+cUYaE=
-----END EC PRIVATE KEY-----

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,105 @@
#video-wrapper {
text-align: center;
padding-top: 10px;
padding-bottom: 10px;
}
#video-wrapper video {
max-width: 90vw;
max-height: 70vh;
box-shadow: 0 10px 25px 0 rgba(0, 0, 0, 0.50);
outline: none;
}
#stream-page-ui {
display: none;
}
#stream-serve-page-ui {
display: none;
}
#video-container {
display: none;
}
#output {
padding: 20px 0;
overflow: scroll;
font-size: 10px;
margin: 0;
max-height: 400px;
}
#output::-webkit-scrollbar {
width: 0;
background: transparent;
}
#qrcode {
width: 128px;
margin: 0 auto;
}
#footer {
margin-bottom: 20px;
}
.header-room {
font-size: 1rem;
}
.panel {
max-width: 400px;
padding: 20px;
margin: 20px auto;
}
.panel .separator {
height: 50px;
}
.help-text {
font-size: 0.8rem;
}
.container-small {
max-width: 760px;
}
.config-panel {
border: 1px solid #e2e3e5;
padding: 20px;
border-radius: 10px;
margin-top: 40px;
margin-bottom: 40px;
}
.config-panel label {
font-size: 0.8rem;
margin: 2px;
}
.config-panel select {
font-size: 0.8rem;
}
.config-panel input, .config-panel textarea {
font-size: 0.8rem;
font-family: Consolas, monospace;
}
.table-meta {
font-size: 12px;
}
.table-meta td:first-child {
width: 150px;
}
@media (max-width: 576px) {
.btn-block-xs-only {
display: block;
width: 100%;
}
}

View File

@@ -0,0 +1,524 @@
"use strict";
// todo: deprecate
const iceConfig = {
iceServers: [{
urls: [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
],
}],
iceCandidatePoolSize: 10,
};
const displayMediaOptions = {
noConstraint: {
video: true,
audio: true,
},
v720p30: {
video: {
height: 720,
frameRate: 30,
},
audio: true,
},
v480p60: {
video: {
height: 480,
frameRate: 60,
},
audio: true,
},
};
const rtpPeerConnectionOptions = {
stunGoogle: {
iceServers: [{
urls: [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
],
}],
iceCandidatePoolSize: 10,
},
noStun: {
iceServers: [],
}
};
const preset = {
balanced: {
displayMediaOption: displayMediaOptions.v720p30,
rtpPeerConnectionOption: rtpPeerConnectionOptions.stunGoogle,
},
performance: {
displayMediaOption: displayMediaOptions.v480p60,
rtpPeerConnectionOption: rtpPeerConnectionOptions.stunGoogle,
},
highQuality: {
displayMediaOption: displayMediaOptions.noConstraint,
rtpPeerConnectionOption: rtpPeerConnectionOptions.stunGoogle,
},
balancedLanOnly: {
displayMediaOption: displayMediaOptions.v720p30,
rtpPeerConnectionOption: rtpPeerConnectionOptions.noStun,
},
performanceLanOnly: {
displayMediaOption: displayMediaOptions.v480p60,
rtpPeerConnectionOption: rtpPeerConnectionOptions.noStun,
},
highQualityLanOnly: {
displayMediaOption: displayMediaOptions.noConstraint,
rtpPeerConnectionOption: rtpPeerConnectionOptions.noStun,
},
};
const LaplaceVar = {
ui: {},
};
function avg(arr) {
return (arr.reduce((a, b) => a + b, 0) / arr.length) | 0
}
function print(s) {
LaplaceVar.ui.output.innerHTML += s + '\n';
}
function getBaseUrl() {
return `${window.location.protocol}//${window.location.host}`
}
function getStreamUrl() {
return `${getBaseUrl()}/?stream=1`;
}
function getJoinUrl(roomID) {
return `${getBaseUrl()}/?id=${roomID}`;
}
function updateRoomUI() {
LaplaceVar.ui.panel.style.display = 'none';
LaplaceVar.ui.videoContainer.style.display = 'block';
LaplaceVar.ui.streamPageUI.style.display = 'block';
if (LaplaceVar.roomID) {
const joinUrl = getJoinUrl(LaplaceVar.roomID);
LaplaceVar.ui.qrcodeObj = new QRCode(LaplaceVar.ui.qrcode, {
text: joinUrl,
width: 128,
height: 128,
});
LaplaceVar.ui.roomText.innerHTML = '<b>RoomID:</b> #' + LaplaceVar.roomID;
LaplaceVar.ui.joinLinkText.innerHTML = joinUrl;
LaplaceVar.ui.joinLinkText.href = joinUrl;
}
}
function initUI() {
LaplaceVar.ui.btnStream = document.getElementById('btnStream');
LaplaceVar.ui.btnStartStream = document.getElementById('btnStartStream');
LaplaceVar.ui.inputRoomID = document.getElementById('inputRoomID');
LaplaceVar.ui.inputDisplayMediaOption = document.getElementById('inputDisplayMediaOption');
LaplaceVar.ui.inputRTPPeerConnectionOption = document.getElementById('inputRTPPeerConnectionOption');
LaplaceVar.ui.joinLinkText = document.getElementById("join-link");
LaplaceVar.ui.joinForm = document.getElementById('joinForm');
LaplaceVar.ui.output = document.getElementById('output');
LaplaceVar.ui.qrcode = document.getElementById("qrcode");
LaplaceVar.ui.panel = document.getElementById('panel');
LaplaceVar.ui.roomText = document.getElementById('room-text');
LaplaceVar.ui.statusNumConn = document.getElementById('statusNumConn');
LaplaceVar.ui.statusPeers = document.getElementById('statusPeers');
LaplaceVar.ui.statusPing = document.getElementById('statusPing');
LaplaceVar.ui.selectOptionPreset = document.getElementById('inputOptionPreset');
LaplaceVar.ui.streamPageUI = document.getElementById('stream-page-ui');
LaplaceVar.ui.streamServePageUI = document.getElementById('stream-serve-page-ui');
LaplaceVar.ui.video = document.getElementById('mainVideo');
LaplaceVar.ui.videoContainer = document.getElementById('video-container');
LaplaceVar.ui.joinForm.onsubmit = async e => {
e.preventDefault();
LaplaceVar.roomID = LaplaceVar.ui.inputRoomID.value;
window.history.pushState('', '', getJoinUrl(LaplaceVar.roomID));
await doJoin(LaplaceVar.roomID);
};
LaplaceVar.ui.btnStream.onclick = async () => {
window.history.pushState('', '', getStreamUrl());
await doStream();
};
LaplaceVar.ui.btnStartStream.onclick = () => {
LaplaceVar.ui.streamServePageUI.style.display = 'none';
const mediaOption = JSON.parse(LaplaceVar.ui.inputDisplayMediaOption.value);
const pcOption = JSON.parse(LaplaceVar.ui.inputRTPPeerConnectionOption.value);
return startStream(mediaOption, pcOption);
};
for (const presetName of Object.keys(preset)) {
const optionElement = document.createElement('option');
optionElement.appendChild(document.createTextNode(presetName));
optionElement.value = presetName;
LaplaceVar.ui.selectOptionPreset.appendChild(optionElement);
}
LaplaceVar.ui.selectOptionPreset.onchange = () => {
const v = LaplaceVar.ui.selectOptionPreset.value;
if (preset[v] != null) {
LaplaceVar.ui.inputDisplayMediaOption.value = JSON.stringify(preset[v].displayMediaOption, null, 1);
LaplaceVar.ui.inputRTPPeerConnectionOption.value = JSON.stringify(preset[v].rtpPeerConnectionOption, null, 1);
}
};
const defaultPresetValue = Object.keys(preset)[0];
LaplaceVar.ui.inputDisplayMediaOption.value = JSON.stringify(preset[defaultPresetValue].displayMediaOption, null, 1);
LaplaceVar.ui.inputRTPPeerConnectionOption.value = JSON.stringify(preset[defaultPresetValue].rtpPeerConnectionOption, null, 1);
print("Logs:");
print("[+] Page loaded");
}
function updateStatusUIStream() {
LaplaceVar.status.peers = Object.keys(LaplaceVar.pcs).map(s => s.split('$')[1]);
LaplaceVar.status.numConn = LaplaceVar.status.peers.length;
LaplaceVar.ui.statusPeers.innerHTML = LaplaceVar.status.peers
.map(s => `${s} (${LaplaceVar.pings[LaplaceVar.roomID + '$' + s]} ms)`).join(', ');
LaplaceVar.ui.statusNumConn.innerHTML = LaplaceVar.status.numConn;
}
function updateStatusUIJoin() {
LaplaceVar.ui.statusNumConn.innerHTML = LaplaceVar.status.numConn;
LaplaceVar.ui.statusPeers.innerHTML = LaplaceVar.status.peers.map(s => LaplaceVar.sessionID.endsWith(s) ? s + ' (you)' : s).join(', ');
}
function getWebsocketUrl() {
if (window.location.protocol === "https:") {
return `wss://${window.location.host}`
} else {
return `ws://${window.location.host}`
}
}
async function newRoom(rID) {
print("[+] Get room ID: " + rID);
LaplaceVar.roomID = rID;
updateRoomUI();
}
async function newSessionStream(sessionID, pcOption) {
print('[+] New session: ' + sessionID);
LaplaceVar.pcs[sessionID] = new RTCPeerConnection(pcOption);
LaplaceVar.pcs[sessionID].onicecandidate = e => {
print('[+] Debug onicecandidate: ' + JSON.stringify(e.candidate));
if (!e.candidate) {
print('[+] Debug onicecandidate: got final candidate!');
return;
}
print('[+] Send addCallerIceCandidate to websocket: ' + JSON.stringify(e.candidate));
LaplaceVar.socket.send(JSON.stringify({
Type: "addCallerIceCandidate",
SessionID: sessionID,
Value: JSON.stringify(e.candidate),
}))
};
LaplaceVar.pcs[sessionID].oniceconnectionstatechange = () => {
print('[+] Debug oniceconnectionstatechange ' + LaplaceVar.pcs[sessionID].iceConnectionState);
if (LaplaceVar.pcs[sessionID].iceConnectionState === 'disconnected') {
print("[-] Disconnected with a Peer " + sessionID);
LaplaceVar.pcs[sessionID].close();
delete LaplaceVar.pcs[sessionID];
delete LaplaceVar.dataChannels[sessionID];
delete LaplaceVar.pings[sessionID];
delete LaplaceVar.pingHistories[sessionID];
updateStatusUIStream();
}
};
updateStatusUIStream();
LaplaceVar.dataChannels[sessionID] = LaplaceVar.pcs[sessionID].createDataChannel('ping');
LaplaceVar.dataChannels[sessionID].addEventListener('open', () => {
print('[+] Start ping interval: ', sessionID);
LaplaceVar.pingHistories[sessionID] = [];
LaplaceVar.pingIntervals[sessionID] = window.setInterval(() => {
const now = new Date().getTime();
LaplaceVar.dataChannels[sessionID].send('ping ' + now.toString());
LaplaceVar.dataChannels[sessionID].send('status ' + JSON.stringify(LaplaceVar.status));
}, 5000);
});
LaplaceVar.dataChannels[sessionID].addEventListener('close', () => {
print('[+] Clear ping interval: ', sessionID);
window.clearInterval(LaplaceVar.pingIntervals[sessionID]);
});
LaplaceVar.dataChannels[sessionID].addEventListener('message', e => {
if (e.data.startsWith('ping')) {
LaplaceVar.dataChannels[sessionID].send('pong' + e.data.slice(4));
} else if (e.data.startsWith('pong')) {
const now = new Date().getTime();
const then = parseInt(e.data.slice(4));
if (!isNaN(then)) {
LaplaceVar.pingHistories[sessionID].push(now - then);
if (LaplaceVar.pingHistories[sessionID].length > 3) {
LaplaceVar.pingHistories[sessionID].shift()
}
LaplaceVar.pings[sessionID] = avg(LaplaceVar.pingHistories[sessionID]);
updateStatusUIStream();
}
}
});
LaplaceVar.mediaStream.getTracks().forEach(track => {
LaplaceVar.pcs[sessionID].addTrack(track, LaplaceVar.mediaStream);
});
print('[+] Creating offer');
const offer = await LaplaceVar.pcs[sessionID].createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await LaplaceVar.pcs[sessionID].setLocalDescription(offer);
print('[+] Send offer to websocket: ' + JSON.stringify(offer));
LaplaceVar.socket.send(JSON.stringify({
Type: "gotOffer",
SessionID: sessionID,
Value: JSON.stringify(offer),
}));
}
async function addCalleeIceCandidate(sessionID, v) {
print('[+] Debug addCalleeIceCandidate ' + sessionID + ' ' + JSON.stringify(v));
return LaplaceVar.pcs[sessionID].addIceCandidate(v);
}
async function gotAnswer(sessionID, v) {
print('[+] Debug gotAnswer ' + sessionID + ' ' + JSON.stringify(v));
return LaplaceVar.pcs[sessionID].setRemoteDescription(new RTCSessionDescription(v));
}
async function doStream() {
LaplaceVar.ui.panel.style.display = 'none';
LaplaceVar.ui.streamServePageUI.style.display = 'block';
}
async function startStream(displayMediaOption, pcOption) {
LaplaceVar.pcs = {}; // contains RTCPeerConnections
LaplaceVar.dataChannels = {};
LaplaceVar.pings = {};
LaplaceVar.pingHistories = {};
LaplaceVar.pingIntervals = {};
LaplaceVar.status = {
numConn: 0,
peers: [],
};
updateRoomUI();
print('[+] Initiate media: capture display media');
try {
// noinspection JSUnresolvedFunction
LaplaceVar.mediaStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOption);
} catch {
alert('Streaming from this device is not supported. \n\nGoogle reference: getDisplayMedia');
leaveRoom()
}
LaplaceVar.ui.video.srcObject = LaplaceVar.mediaStream;
LaplaceVar.ui.video.muted = true; // prevent duplicate sound played
print('[+] Initiate websocket');
LaplaceVar.socket = new WebSocket(getWebsocketUrl() + '/ws_serve');
LaplaceVar.socket.onerror = () => {
alert('WebSocket error');
leaveRoom();
};
LaplaceVar.socket.onopen = async function () {
print("[+] Connected to websocket");
};
LaplaceVar.socket.onmessage = async function (e) {
try {
const jsonData = JSON.parse(e.data);
if (jsonData.Type !== 'beat') {
print("[+] Received websocket message: " + JSON.stringify(e.data));
}
if (jsonData.Type === "newRoom") {
await newRoom(jsonData.Value);
} else if (jsonData.Type === "newSession") {
await newSessionStream(jsonData.SessionID, pcOption);
} else if (jsonData.Type === "addCalleeIceCandidate") {
await addCalleeIceCandidate(jsonData.SessionID, JSON.parse(jsonData.Value));
} else if (jsonData.Type === "gotAnswer") {
await gotAnswer(jsonData.SessionID, JSON.parse(jsonData.Value));
}
} catch (e) {
print("[!] ERROR: " + e);
console.error(e);
}
};
}
async function newSessionJoin(sID) {
print('[+] New session: ' + sID);
LaplaceVar.sessionID = sID;
LaplaceVar.pc = new RTCPeerConnection(iceConfig);
LaplaceVar.pc.onicecandidate = e => {
print('[+] Debug onicecandidate: ' + JSON.stringify(e.candidate));
if (!e.candidate) {
print('[+] Debug onicecandidate: got final candidate!');
return;
}
print('[+] Send addCalleeIceCandidate to websocket: ' + JSON.stringify(e.candidate));
LaplaceVar.socket.send(JSON.stringify({
Type: "addCalleeIceCandidate",
SessionID: LaplaceVar.sessionID,
Value: JSON.stringify(e.candidate),
}))
};
LaplaceVar.pc.oniceconnectionstatechange = () => {
print('[+] pc.oniceconnectionstatechange ' + LaplaceVar.pc.iceConnectionState);
if (LaplaceVar.pc.iceConnectionState === 'disconnected') {
print("[-] Disconnected with Peer");
LaplaceVar.pc.close();
LaplaceVar.pc = null;
}
};
LaplaceVar.pc.ontrack = event => {
// print('[+] Debug pc.ontrack: ' + JSON.stringify(event.track));
// /* does not work on safari */
// event.streams[0].getTracks().forEach(track => {
// print('[+] Debug addTrack ' + JSON.stringify(track));
// LaplaceVar.mediaStream.addTrack(track)
// });
LaplaceVar.mediaStream.addTrack(event.track);
LaplaceVar.ui.video.srcObject = LaplaceVar.mediaStream;
try {
LaplaceVar.ui.video.play();
} catch {}
};
LaplaceVar.pc.addEventListener('datachannel', e => {
LaplaceVar.dataChannel = e.channel;
LaplaceVar.dataChannel.addEventListener('open', () => {
print('[+] Start ping interval');
LaplaceVar.pingHistory = [];
LaplaceVar.pingInterval = window.setInterval(() => {
const now = new Date().getTime();
LaplaceVar.dataChannel.send('ping ' + now.toString());
}, 1000);
});
LaplaceVar.dataChannel.addEventListener('close', () => {
print('[+] Clear ping interval');
clearInterval(LaplaceVar.pingInterval);
});
LaplaceVar.dataChannel.addEventListener('message', e => {
if (e.data.startsWith('ping')) {
LaplaceVar.dataChannel.send('pong' + e.data.slice(4));
} else if (e.data.startsWith('pong')) {
const now = new Date().getTime();
const then = parseInt(e.data.slice(4));
if (!isNaN(then)) {
LaplaceVar.pingHistory.push(now - then);
if (LaplaceVar.pingHistory.length > 3) {
LaplaceVar.pingHistory.shift()
}
LaplaceVar.ping = avg(LaplaceVar.pingHistory);
LaplaceVar.ui.statusPing.innerHTML = LaplaceVar.ping + ' ms';
}
} else if (e.data.startsWith('status')) {
LaplaceVar.status = JSON.parse(e.data.slice(7));
updateStatusUIJoin();
}
});
});
}
async function addCallerIceCandidate(sID, v) {
print('[+] Debug addCallerIceCandidate ' + sID + ' ' + JSON.stringify(v));
if (LaplaceVar.sessionID !== sID) return;
return LaplaceVar.pc.addIceCandidate(v);
}
async function gotOffer(sID, v) {
print('[+] Debug gotOffer ' + sID + ' ' + JSON.stringify(v));
if (LaplaceVar.sessionID !== sID) return;
await LaplaceVar.pc.setRemoteDescription(new RTCSessionDescription(v));
print('[+] Create answer');
const answer = await LaplaceVar.pc.createAnswer();
await LaplaceVar.pc.setLocalDescription(answer);
print('[+] Send answer to websocket: ' + JSON.stringify(answer));
LaplaceVar.socket.send(JSON.stringify({
Type: "gotAnswer",
SessionID: LaplaceVar.sessionID,
Value: JSON.stringify(answer),
}))
}
async function doJoin(roomID) {
if (roomID) {
LaplaceVar.roomID = roomID;
} else {
return alert('roomID is not provided');
}
// normalize roomID starting with #
LaplaceVar.roomID = LaplaceVar.roomID.startsWith('#') ? LaplaceVar.roomID.slice(1) : LaplaceVar.roomID;
LaplaceVar.status = {
numConn: 0,
peers: [],
};
updateRoomUI();
print('[+] Initiate media: set remote source');
LaplaceVar.mediaStream = new MediaStream();
LaplaceVar.ui.video.srcObject = LaplaceVar.mediaStream;
print('[+] Initiate websocket');
LaplaceVar.socket = new WebSocket(getWebsocketUrl() + "/ws_connect?id=" + LaplaceVar.roomID);
LaplaceVar.socket.onerror = () => {
alert('WebSocket error');
leaveRoom();
};
LaplaceVar.socket.onopen = async function () {
print("[+] Connected to websocket");
};
LaplaceVar.socket.onmessage = async function (e) {
try {
const jsonData = JSON.parse(e.data);
if (jsonData.Type !== 'beat') {
print("[+] Received websocket message: " + JSON.stringify(e.data));
}
if (jsonData.Type === "newSession") {
await newSessionJoin(jsonData.SessionID);
} else if (jsonData.Type === "addCallerIceCandidate") {
await addCallerIceCandidate(jsonData.SessionID, JSON.parse(jsonData.Value));
} else if (jsonData.Type === "gotOffer") {
await gotOffer(jsonData.SessionID, JSON.parse(jsonData.Value));
} else if (jsonData.Type === 'roomNotFound') {
alert('Room not found');
leaveRoom();
} else if (jsonData.Type === 'roomClosed') {
alert('Room closed');
}
} catch (e) {
print("[!] ERROR: " + e);
console.error(e)
}
};
}
function routeByUrl() {
const u = new URL(window.location);
const paramId = u.searchParams.get('id');
if (paramId && paramId.length > 0) {
return doJoin(paramId);
}
const paramStream = u.searchParams.get('stream');
if (paramStream && paramStream.length > 0) {
return doStream();
}
}
function leaveRoom() {
window.location.href = getBaseUrl();
}
initUI();
routeByUrl();

1
laplace/files/static/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

5
laplace/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module laplace
go 1.12
require github.com/gorilla/websocket v1.4.2

2
laplace/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

33
laplace/main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"flag"
"laplace/core"
"log"
"math/rand"
"net/http"
"time"
)
func main() {
addr := flag.String("addr", "0.0.0.0:443", "Listen address")
tls := flag.Bool("tls", true, "Use TLS")
certFile := flag.String("certFile", "files/server.crt", "TLS cert file")
keyFile := flag.String("keyFile", "files/server.key", "TLS key file")
flag.Parse()
rand.Seed(time.Now().UnixNano())
server := core.GetHttp()
if *tls {
log.Println("Listening on TLS:", *addr)
if err := http.ListenAndServeTLS(*addr, *certFile, *keyFile, server); err != nil {
log.Fatalln(err)
}
} else {
log.Println("Listening:", *addr)
if err := http.ListenAndServe(*addr, server); err != nil {
log.Fatalln(err)
}
}
}