Compare commits

..

17 Commits

6 changed files with 336 additions and 90 deletions

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Dockerfile
FROM golang:tip-alpine3.22 AS builder
WORKDIR /app
# RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
RUN go build -ldflags="-s -w" -o /app/echo .
FROM alpine:3.22
# RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates
RUN addgroup -S app && adduser -S app -G app
USER app
WORKDIR /app
COPY --from=builder /app/echo /app/echo
COPY --from=builder /app/front_files /app/front_files
# EXPOSE is optional with custom networking, we can keep or remove it as we are using custom network with compose
EXPOSE 8900
ENTRYPOINT ["/app/echo"]

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
echo:
build:
context: .
dockerfile: Dockerfile
image: echo-image:latest
container_name: echo
restart: unless-stopped
ports:
- "8900:8900"
networks:
- echo-net
environment:
- TZ=UTC
networks:
echo-net:
name: echo-net

View File

@@ -7,7 +7,7 @@
} }
:root { :root {
--bg: #0a3558; /* ocean-ish blue */ --bg: #041f39;
--bg-accent: #0f4470; --bg-accent: #0f4470;
--text: #f5f7fb; --text: #f5f7fb;
--muted: #c3cfde; --muted: #c3cfde;
@@ -15,10 +15,10 @@
--primary-hover: #2f9ce2; --primary-hover: #2f9ce2;
--secondary: #ffffff; --secondary: #ffffff;
--secondary-text: #0a3558; --secondary-text: #0a3558;
--shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.35); --shadow-soft: 0 20px 40px rgba(7, 15, 30, 0.5);
--radius-lg: 18px; --radius-lg: 22px;
--transition: 0.18s ease-out; --transition: 0.18s ease-out;
--font-main: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", --font-main: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont,
sans-serif; sans-serif;
} }
@@ -29,11 +29,12 @@ body {
body { body {
font-family: var(--font-main); font-family: var(--font-main);
background: radial-gradient(circle at top, #1c5b90, var(--bg)); background: linear-gradient(135deg, #05284b, #03182d 55%);
color: var(--text); color: var(--text);
display: flex; display: flex;
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
padding: 32px;
} }
/* Layout */ /* Layout */
@@ -57,12 +58,20 @@ body {
} }
.logo { .logo {
font-size: 2.4rem; font-size: 2.8rem;
letter-spacing: 0.12em; letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
color: #fafdff; color: #fafdff;
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.45); text-shadow: 0 6px 22px rgba(0, 0, 0, 0.55);
margin-right: 18px;
}
.tagline {
color: rgba(245, 247, 251, 0.75);
font-size: 1rem;
letter-spacing: 0.08em;
text-transform: uppercase;
} }
/* Main card */ /* Main card */
@@ -75,13 +84,25 @@ body {
} }
.card { .card {
background: linear-gradient(135deg, var(--bg-accent), #0b2b44); background: linear-gradient(160deg, rgba(16, 40, 73, 0.95), rgba(7, 26, 45, 0.92));
padding: 28px 24px; padding: 32px 30px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
width: 100%; width: 100%;
max-width: 460px; max-width: 520px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.07);
}
.card-heading {
font-size: 1.35rem;
font-weight: 600;
margin-bottom: 6px;
}
.card-subheading {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 20px;
} }
.actions { .actions {
@@ -96,8 +117,8 @@ body {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: auto;
padding: 12px 18px; padding: 14px 20px;
border-radius: 999px; border-radius: 999px;
border: none; border: none;
font-size: 1rem; font-size: 1rem;
@@ -125,6 +146,10 @@ body {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
} }
.actions .btn {
width: 100%;
}
.btn-secondary { .btn-secondary {
background: var(--secondary); background: var(--secondary);
color: var(--secondary-text); color: var(--secondary-text);
@@ -150,18 +175,24 @@ body {
.input { .input {
width: 100%; width: 100%;
padding: 10px 12px; padding: 14px 16px;
border-radius: 10px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(3, 16, 32, 0.5); background: rgba(5, 19, 36, 0.7);
color: var(--text); color: var(--text);
font-size: 0.95rem; font-size: 1.05rem;
outline: none; outline: none;
margin-bottom: 10px; margin-bottom: 14px;
transition: border-color var(--transition), box-shadow var(--transition), transition: border-color var(--transition), box-shadow var(--transition),
background-color var(--transition); background-color var(--transition);
} }
.chat-input-row .input {
font-size: 1.05rem;
padding: 18px 18px;
border-radius: 18px;
}
.input::placeholder { .input::placeholder {
color: rgba(195, 207, 222, 0.8); color: rgba(195, 207, 222, 0.8);
} }
@@ -207,7 +238,15 @@ body {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 4px; margin-bottom: 12px;
}
.chat-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 2px;
} }
.chat-session { .chat-session {
@@ -217,32 +256,62 @@ body {
.chat-log { .chat-log {
flex: 1; flex: 1;
min-height: 180px; min-height: 220px;
max-height: 320px; max-height: 420px;
margin-bottom: 8px; margin-bottom: 12px;
padding: 12px; padding: 16px;
border-radius: 10px; border-radius: 16px;
background: rgba(3, 16, 32, 0.65); background: rgba(4, 15, 30, 0.8);
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
} }
.chat-input-row { .chat-input-row {
display: flex; display: flex;
gap: 8px; gap: 10px;
align-items: center;
background: rgba(3, 12, 24, 0.7);
border-radius: 22px;
padding: 4px 6px 4px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
} }
.chat-input-row .input { .chat-input-row .input {
flex: 1; flex: 1 1 auto;
min-width: 0;
margin-bottom: 0; margin-bottom: 0;
border: none;
background: transparent;
height: 44px;
line-height: 44px;
padding: 0;
}
.chat-send-btn {
width: 44px;
min-width: 44px;
height: 44px;
padding: 0;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
.chat-send-btn .send-icon {
width: 18px;
height: 18px;
fill: currentColor;
} }
.chat-message { .chat-message {
padding: 8px 10px; padding: 10px 14px;
border-radius: 14px; border-radius: 16px;
font-size: 0.9rem; font-size: 1rem;
max-width: 80%; max-width: 80%;
} }

View File

@@ -14,10 +14,13 @@
<div class="app"> <div class="app">
<header class="app-header"> <header class="app-header">
<h1 class="logo">Echo</h1> <h1 class="logo">Echo</h1>
<p class="tagline">Simple peer-to-peer text & files</p>
</header> </header>
<main class="app-main"> <main class="app-main">
<section class="card" id="landing-card"> <section class="card" id="landing-card">
<div class="card-heading">Start a secure session</div>
<div class="card-subheading">Create a fresh room or join with an invite code.</div>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" id="create-session-btn"> <button class="btn btn-primary" id="create-session-btn">
Create a session Create a session
@@ -37,7 +40,10 @@
<section class="card hidden" id="chat-card"> <section class="card hidden" id="chat-card">
<div class="chat-header"> <div class="chat-header">
<div>
<div class="chat-title">Live session</div>
<div class="chat-session" id="chat-session-info"></div> <div class="chat-session" id="chat-session-info"></div>
</div>
<button class="btn btn-secondary" id="leave-session-btn"> <button class="btn btn-secondary" id="leave-session-btn">
Leave session Leave session
</button> </button>
@@ -46,9 +52,14 @@
<div class="chat-log" id="chat-log"></div> <div class="chat-log" id="chat-log"></div>
<form class="chat-input-row" id="chat-form"> <form class="chat-input-row" id="chat-form">
<input type="text" id="chat-input" class="input" placeholder="Type a message..." autocomplete="off" /> <input type="text" id="chat-input" class="input chat-input" placeholder="Type a message..."
<button class="btn btn-primary" id="chat-send-btn"> autocomplete="off" />
Send <button class="btnx btn-primary chat-send-btn" id="chat-send-btn" aria-label="Send message"
type="submit">
<svg class="send-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M3.4 20.6c-.5.2-.9-.3-.7-.8l3-8.2c.1-.3 0-.7-.2-.9l-3-3.4c-.4-.5 0-1.3.7-1.1l18.5 6.2c.6.2.6 1 0 1.2L3.4 20.6zm3.5-9.3c.7.7 1.1 1.6.9 2.5l-1 3 10.8-4-10.7-3.5z" />
</svg>
</button> </button>
</form> </form>
</section> </section>

View File

@@ -33,7 +33,12 @@ console.log("Echo mock frontend loaded");
var currentSessionId = null; var currentSessionId = null;
var currentParty = null; // "A" (offerer) or "B" (answerer) var currentParty = null; // "A" (offerer) or "B" (answerer)
var mockConnected = false; var wsConnected = false;
var ws = null;
var pc = null;
var rc = null;
var lastSignal = null; // saving the last signal to send it on client connect
var dataChannel = null
function setSessionInfo(text) { function setSessionInfo(text) {
var partyLabel = var partyLabel =
@@ -75,7 +80,7 @@ console.log("Echo mock frontend loaded");
function resetState() { function resetState() {
currentSessionId = null; currentSessionId = null;
currentParty = null; currentParty = null;
mockConnected = false; wsConnected = false;
setChatInputEnabled(false); setChatInputEnabled(false);
} }
@@ -100,53 +105,24 @@ console.log("Echo mock frontend loaded");
setSessionInfo( setSessionInfo(
"Session ID: " + currentSessionId + " (share this with your peer)" "Session ID: " + currentSessionId + " (share this with your peer)"
); );
appendMessage(
"system",
"Session created. TODO: open a WebSocket for signaling and send an SDP offer."
);
} else { } else {
setSessionInfo("Joined session: " + currentSessionId); setSessionInfo("Joined session: " + currentSessionId);
appendMessage(
"system",
"Joined session. TODO: connect to the signaling WebSocket and respond with an SDP answer."
);
} }
appendMessage(
"system",
"Mock mode: no real signaling or WebRTC yet. Everything stays local."
);
} }
function mockConnectP2P() {
if (mockConnected) {
return;
}
mockConnected = true;
setChatInputEnabled(true);
appendMessage(
"system",
"Pretend the RTCDataChannel is open now. Replace this with real WebRTC events."
);
}
function sendChatMessage(text) { function sendChatMessage(text) {
if (!mockConnected) {
appendMessage(
"system", dataChannel.send(text)
"Mock mode: sending locally. Wire this up to RTCDataChannel.send()."
);
}
appendMessage("me", text); appendMessage("me", text);
if (!mockConnected) {
appendMessage(
"them",
"(Simulated peer) Replace with your RTCDataChannel onmessage handler."
);
}
} }
async function createSession() { async function createSession() {
@@ -176,11 +152,11 @@ console.log("Echo mock frontend loaded");
appendMessage( appendMessage(
"system", "system",
"TODO: connect to /api/signal/session/" + "Created session"
currentSessionId +
"/party/A via WebSocket."
); );
mockConnectP2P();
await connectWebSocket()
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert(err && err.message ? err.message : "Could not create session."); alert(err && err.message ? err.message : "Could not create session.");
@@ -190,7 +166,7 @@ console.log("Echo mock frontend loaded");
} }
} }
function joinSession() { async function joinSession() {
var id = sessionIdInput.value.trim(); var id = sessionIdInput.value.trim();
if (!id) { if (!id) {
sessionIdInput.focus(); sessionIdInput.focus();
@@ -201,15 +177,165 @@ console.log("Echo mock frontend loaded");
currentParty = "B"; currentParty = "B";
showChat(); showChat();
appendMessage( await connectWebSocket()
"system",
"TODO: connect to /api/signal/session/" +
currentSessionId +
"/party/B via WebSocket."
);
mockConnectP2P();
} }
async function connectWebSocket() {
// supporting ws for local testing.
var schema = window.location.protocol === "https:" ? "wss:" : "ws:"
var wsURL = schema + window.location.host +
"/api/signal/session/" + currentSessionId + "/party/" + currentParty
ws = new WebSocket(wsURL)
ws.onopen = () => {
appendMessage("system", "Connected to WS")
if (currentParty == "A") {
createChannel()
} else {
sendSignal({ "type": "hello" })
}
}
ws.onmessage = async (event) => {
var event_data = event.data
console.log("Received from WS", event_data, typeof (event_data))
var payload = JSON.parse(event_data)
await handleWebSocketEvent(payload)
}
}
function sendSignal(payload) {
if (!payload) {
console.warn("Attempted to send empty signaling payload")
return
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
return
}
try {
ws.send(JSON.stringify(payload));
} catch (err) {
console.error("Failed to send signal", err)
}
}
async function handleWebSocketEvent(payload) {
console.log("Handling WS Event, payload.type", payload.type, payload, typeof (payload))
if (payload.type == "offer") {
await handleOffer(payload)
} else if (payload.type == "hello" && lastSignal) {
sendSignal(lastSignal)
} else if (payload.type == "answer") {
handleAnswer(payload)
} else if (payload.type === "candidate" && payload.candidate) {
try {
await pc.addIceCandidate(new RTCIceCandidate(payload.candidate))
} catch (err) {
console.error("Failed to add remote ICE candidate", err)
}
}
}
async function handleOffer(offer) {
console.log("New offer", offer)
if (!rc) {
rc = new RTCPeerConnection()
}
rc.onicecandidate = e => {
console.log(" NEW ice candidate!! reprinting SDP for rc ")
console.log("ice candidate", e.candidate)
sendSignal({ type: "candidate", candidate: e.candidate })
}
rc.ondatachannel = e => {
const receiveChannel = e.channel;
receiveChannel.onmessage = e => {
console.log("messsage received!!!" + e.data)
appendMessage("them", e.data)
}
receiveChannel.onopen = e => {
console.log("open!!!!")
setChatInputEnabled(true)
};
receiveChannel.onclose = e => {
console.log("closed!!!!!!")
setChatInputEnabled(false)
};
rc.channel = receiveChannel;
dataChannel = receiveChannel
}
await rc.setRemoteDescription(offer)
console.log("Remote description applied")
var answer = await rc.createAnswer()
await rc.setLocalDescription(answer)
console.log("Answer", JSON.stringify(rc.localDescription))
sendSignal(rc.localDescription)
appendMessage("system", "Sent Answer")
lastSignal = rc.localDescription
}
function handleAnswer(answer) {
pc.setRemoteDescription(answer).then(a => {
console.log("Accepted Answer.")
appendMessage("system", "Accepted Answer")
})
}
function createChannel() {
pc = new RTCPeerConnection()
pc.onicecandidate = e => {
console.log(" NEW ice candidate!! on pc reprinting SDP ")
console.log(JSON.stringify(pc.localDescription))
if (pc.localDescription) {
sendSignal(pc.localDescription)
lastSignal = pc.localDescription
appendMessage("system", "Sent Offer")
}
}
const sendChannel = pc.createDataChannel("sendChannel");
sendChannel.onmessage = e => {
console.log("messsage received!!!" + e.data)
appendMessage("them", e.data)
}
sendChannel.onopen = e => {
console.log("open!!!!")
setChatInputEnabled(true)
};
sendChannel.onclose = e => {
console.log("closed!!!!!!")
setChatInputEnabled(false)
};
dataChannel = sendChannel
pc.createOffer().then(o => pc.setLocalDescription(o))
}
createBtn.addEventListener("click", function () { createBtn.addEventListener("click", function () {
createSession(); createSession();
}); });

View File

@@ -7,6 +7,7 @@ import (
func main() { func main() {
fmt.Println("Hello from echo-echo-cho-o") fmt.Println("Hello from echo-echo-cho-o")
fmt.Println("Listening to port 8900")
api.BuildRouter(":8080") api.BuildRouter(":8900")
} }