diff --git a/server/src/index.js b/server/src/index.js index b277f11..c1bec51 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1,12 +1,53 @@ +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; import { WebSocketServer } from "ws"; const PORT = Number(process.env.PORT) || 48085; const HOST = process.env.HOST || ""; -const options = { port: PORT }; -if (HOST) options.host = HOST; -const wss = new WebSocketServer(options); +const SITE = process.env.SITE_URL || "https://offmic.xeron.be"; const rooms = new Map(); +const landing = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "landing.html") +); + +const robots = `User-agent: *\nAllow: /\nSitemap: ${SITE}/sitemap.xml\n`; + +const sitemap = ` + + ${SITE}/ + +`; + +const server = createServer((req, res) => { + const path = (req.url || "/").split("?")[0]; + + if (req.method === "GET" && path === "/robots.txt") { + res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); + res.end(robots); + return; + } + + if (req.method === "GET" && path === "/sitemap.xml") { + res.writeHead(200, { "content-type": "application/xml; charset=utf-8" }); + res.end(sitemap); + return; + } + + if (req.method === "GET" && (req.headers.accept || "").includes("text/html")) { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(landing); + return; + } + + res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); + res.end("OffMic signaling OK\n"); +}); + +const wss = new WebSocketServer({ server }); + function roomSet(room) { if (!rooms.has(room)) rooms.set(room, new Map()); return rooms.get(room); @@ -82,4 +123,9 @@ wss.on("connection", (ws) => { ws.on("error", () => onLeave(ws)); }); -console.log(`OffMic signaling server listening on port ${PORT}`); +if (HOST) server.listen(PORT, HOST); +else server.listen(PORT); + +server.on("listening", () => { + console.log(`OffMic signaling server listening on port ${PORT}`); +}); diff --git a/server/src/landing.html b/server/src/landing.html new file mode 100644 index 0000000..5005d22 --- /dev/null +++ b/server/src/landing.html @@ -0,0 +1,532 @@ + + + + + +OffMic, a private voice channel for your team during Teams meetings + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+

Mute yourself in Teams.
Your team hears you.

+

A private voice channel between your teammates during a Teams meeting, driven by one thing only: your Teams microphone state.

+

Your team needs to talk something over, right now, before answering the client. Without switching to another tool.

+ +

Free and open source, AGPL-3.0. Chrome, Manifest V3, loaded unpacked from the repository. The extension already points at a hosted relay, so there is nothing to deploy to get started.

+
+ +
+
+

One gesture you already make

+

No extra button to manage, no shortcut to memorize. You mute yourself the way you always do, and that gesture becomes the trigger.

+
+
+
+ Teams mic muted +

You talk to your team

+

The client hears nothing. Your voice goes only to your teammates, on the private channel.

+
+
You talk to
your team
+
You hear
your team
+
+
+
+ Teams mic live +

You talk to the client

+

The private channel closes on the way out. Your team keeps reaching you, unless you turn that off.

+
+
You talk to
the client
+
You hear
your team, on by default
+
+
+
+
+ +
+
+

How it works

+

OffMic never controls the Teams microphone. It watches it.

+
+
+
+
1
+

Detect

+

A content script observes the Teams web mic button and derives the muted or live state. English and French interfaces are recognized, with a polling fallback.

+
+
+
2
+

Capture

+

An offscreen document opens your microphone alongside Teams, without ever taking it away from the meeting.

+
+
+
3
+

Decide

+

The outgoing track is enabled only when the connection is up and the Teams mic is muted. Two conditions, no ambiguity.

+
+
+
+
+
+
Alice
+ +
Bob
+
+
+
+ SDP / ICE +
+
+
+ signaling server + never any media +
+
+
+

Voice travels peer to peer over WebRTC, in a mesh. It never goes through a server. The signaling server only introduces peers to each other, long enough to exchange SDP descriptions and ICE candidates. Once the peers are connected, nothing flows through it anymore.

+
+ +
+
+

What's in the box

+

Small on purpose. One job, done without ceremony.

+
+
+

Mute-driven

Your Teams microphone state is the only switch. Nothing else to operate mid-meeting.

+

Peer-to-peer audio

WebRTC mesh, end-to-end encrypted by DTLS-SRTP. Media never touches a server.

+

Rooms

A shared team code is the only secret. No account, no sign-up, no invite flow.

+

Keep listening

Hear your team even while your Teams mic is live. On by default, switchable off.

+

Five languages

English, French, Dutch, German and Spanish, following your browser or pinned by hand.

+

Light and dark

Follows your browser theme, or forced either way from the popup settings.

+

Your own relay

Point the extension at any server you run. The address is editable straight from the popup.

+

Nothing stored

Rooms live in memory on the relay and are never written to disk. Settings stay in your browser.

+
+
+ +
+
+
+

Before you install

+
+

The constraints are real and worth knowing up front, because one of them is not a software problem at all:

+
    +
  • Headphones are mandatory. If your team's voice comes out of your speakers while your Teams mic is live, the client will hear it. No software can fix that.
  • +
  • Teams web only. A browser extension has no reach into the Teams desktop client.
  • +
  • Detection depends on the Teams DOM. Microsoft can change its UI and break it.
  • +
  • Chrome for now. The offscreen document is Chrome specific. A Firefox port is planned.
  • +
  • Up to 5 or 6 people. Beyond that, a peer-to-peer mesh costs too much upstream bandwidth.
  • +
+

No account, no sign-up, no usage tracking. The signaling server is a dependency-light Node service, and it is the one serving this page.

+
+
+
+ + + +