diff --git a/README.md b/README.md new file mode 100644 index 0000000..beff5d0 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# WireGuard GUI + +A desktop application for generating WireGuard server and client configuration files through a graphical interface, built with [Electron](https://www.electronjs.org/). + +Instead of hand-editing `.conf` files, you fill in a form and get ready-to-use WireGuard configs, including all the `iptables` rules, instantly. + +## Getting Started + +### Prerequisites + +- [Node.js](https://nodejs.org/) v18 or later +- npm + +### Install and run + +```bash +npm install +npm start +``` + +## Usage + +1. Fill in the **Server** panel on the left -> interface, address, port, keys, and any optional settings +2. Add one or more clients with the **New client** button on the right +3. Fill in each client's keys and toggle internet access as needed +4. Click **Sync** -> both config previews update immediately +5. Use the **Copy** button on either panel to copy the config text to your clipboard +6. Use **Save JSON** in the header to persist the full setup, and **Load** to restore it later + +### Field reference + +**Server** + +| Field | Description | +| --------------------- | ------------------------------------------------------ | +| Network interface | WireGuard interface name, e.g. `wg0` | +| Server address | Internal VPN address in CIDR, e.g. `10.0.0.1/24` | +| Listen port | UDP port WireGuard listens on, e.g. `51820` | +| LAN between clients | Allows peers to reach each other directly | +| Forwarded ports | Comma-separated list of ports to DNAT into the VPN | +| Private / Public key | WireGuard base64 key pair for the server | +| Public address | Hostname or IP clients use to reach the server | +| Running in Docker | Uses the Docker host interface in iptables rules | +| Docker host interface | The physical interface on the Docker host, e.g. `eth0` | + +**Client** (per peer) + +| Field | Description | +| -------------------- | --------------------------------------------------------- | +| Client name | Label used in tabs and config comments | +| Internet traffic | When on, routes all traffic through the VPN (`0.0.0.0/0`) | +| Private / Public key | WireGuard base64 key pair for this client | + +### Key generation + +WireGuard keys can be generated on the server with: + +```bash +wg genkey | tee privatekey | wg pubkey > publickey +``` + +--- + +## Screenshots + + + + diff --git a/docs/Screenshot01.png b/docs/Screenshot01.png new file mode 100644 index 0000000..87556ef Binary files /dev/null and b/docs/Screenshot01.png differ diff --git a/docs/Screenshot02.png b/docs/Screenshot02.png new file mode 100644 index 0000000..ec59285 Binary files /dev/null and b/docs/Screenshot02.png differ diff --git a/package.json b/package.json index bd16eda..503a6a9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "license": "MIT", "author": "Jose Jimenez", "type": "commonjs", - "main": "main.js", + "main": "src/main.js", "scripts": { "start": "electron .", "test": "test", diff --git a/src/gui/gui.html b/src/gui/gui.html new file mode 100644 index 0000000..f9c40d1 --- /dev/null +++ b/src/gui/gui.html @@ -0,0 +1,269 @@ + + + + + + + + + + WireGuard GUI + + +
+
+ WireGuard + WireGuard GUI +
+
+ + + +
+
+ +
+ +
+
+ SERVER +

Server Configuration

+
+ +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ + + +
+ + + Disabled +
+
+ + + Comma-separated list +
+ + + +
+ + +
+
+ + +
+ + + +
+ + + Used as Endpoint in client configs +
+ + + +
+ + + No +
+ +
+ + +
+
+ wg0.conf + +
+
+# Fill in server fields and click Sync
+
+
+
+ + +
+
+ CLIENT +

Client Configuration

+
+ +
+ +
+
+ +
+ +
+
+ + + + +

Add a client to get started

+
+
+ + +
+
+ client.conf + + +
+
+# No client selected
+
+
+
+
+ + + + diff --git a/src/gui/gui.js b/src/gui/gui.js new file mode 100644 index 0000000..bbf9803 --- /dev/null +++ b/src/gui/gui.js @@ -0,0 +1,930 @@ +// WireGuard GUI — gui.js +// State management, validation, config generation + +/***************************************************************************** + * State + *****************************************************************************/ + +let state = { + server: { + iface: "", + address: "", + port: "", + lanBetweenClients: false, + allowedPorts: "", + privateKey: "", + publicKey: "", + publicAddress: "", + docker: false, + dockerIface: "", + }, + clients: [], +}; + +let activeClientId = null; +let clientCounter = 0; + +/***************************************************************************** + * Validation index + * Each entry: { regex, hint, optional, isBool } + * - isBool: skips regex check entirely (checkboxes) + * - optional: empty string is accepted as valid + * - extra: additional predicate run after regex passes + *****************************************************************************/ + +const SERVER_VALIDATORS = { + iface: { + regex: /^[a-zA-Z][a-zA-Z0-9_-]{0,14}$/, + hint: "Interface name: letters, numbers, dash, underscore (max 15 chars)", + }, + address: { + regex: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, + hint: "CIDR notation required, e.g. 10.0.0.1/24", + extra: (v) => { + const [ip, prefix] = v.split("/"); + const parts = ip.split(".").map(Number); + return ( + parts.every((p) => p >= 0 && p <= 255) && +prefix >= 0 && +prefix <= 32 + ); + }, + }, + port: { + regex: /^\d{1,5}$/, + hint: "Port number between 1 and 65535", + extra: (v) => +v >= 1 && +v <= 65535, + }, + lanBetweenClients: { isBool: true }, + allowedPorts: { + regex: /^(\d{1,5}(\s*,\s*\d{1,5})*)?$/, + hint: "Comma-separated port numbers, e.g. 22, 80, 443", + optional: true, + }, + privateKey: { + regex: /^[A-Za-z0-9+/]{43}=$/, + hint: "WireGuard base64 private key (44 chars ending in =)", + }, + publicKey: { + regex: /^[A-Za-z0-9+/]{43}=$/, + hint: "WireGuard base64 public key (44 chars ending in =)", + }, + publicAddress: { + regex: /^[a-zA-Z0-9.-]+$/, + hint: "Hostname or IP address, e.g. vpn.example.com or 203.0.113.1", + }, + docker: { isBool: true }, + dockerIface: { + regex: /^[a-zA-Z][a-zA-Z0-9_-]{0,14}$/, + hint: "Docker host interface name, e.g. eth0 or enp1s0", + optional: true, + }, +}; + +const CLIENT_VALIDATORS = { + name: { + regex: /^.{1,32}$/, + hint: "Client name required (max 32 chars)", + }, + internetTraffic: { isBool: true }, + privateKey: { + regex: /^[A-Za-z0-9+/]{43}=$/, + hint: "WireGuard base64 private key (44 chars ending in =)", + }, + publicKey: { + regex: /^[A-Za-z0-9+/]{43}=$/, + hint: "WireGuard base64 public key (44 chars ending in =)", + }, +}; + +/***************************************************************************** + * Validation helpers + *****************************************************************************/ + +/** + * Runs the validator spec for a single field against a given value. + * + * Looks up the field key in the provided validators map and applies, in order: + * the isBool shortcut, the optional-empty shortcut, the regex test, and any + * extra predicate. Returns a result object so the caller can decide how to + * surface the error. + * + * @param {Object} validators - One of SERVER_VALIDATORS or CLIENT_VALIDATORS. + * @param {string} fieldKey - Key matching an entry in the validators map. + * @param {string} value - Current field value to test. + * @returns {{ valid: boolean, hint?: string }} + */ +function validateValue(validators, fieldKey, value) { + const spec = validators[fieldKey]; + if (!spec) return { valid: true }; + if (spec.isBool) return { valid: true }; + if (spec.optional && (value === "" || value === undefined || value === null)) + return { valid: true }; + if (!spec.regex) return { valid: true }; + if (!spec.regex.test(String(value))) return { valid: false, hint: spec.hint }; + if (spec.extra && !spec.extra(value)) + return { valid: false, hint: spec.hint }; + return { valid: true }; +} + +/** + * Applies or clears visual validation state on an input element. + * + * Toggles the field-invalid / field-valid CSS classes and injects or removes + * a .field-hint inside the parent .field-row. Safe to call repeatedly; + * it updates the existing hint element rather than appending duplicates. + * + * @param {HTMLInputElement} inputEl - The input to decorate. + * @param {boolean} valid - Whether the current value is valid. + * @param {string} [hint] - Error message shown below the input. + */ +function markField(inputEl, valid, hint) { + const wrapper = inputEl.closest(".field-row") || inputEl.parentElement; + const existingHint = wrapper.querySelector(".field-hint"); + + inputEl.classList.toggle("field-invalid", !valid); + inputEl.classList.toggle("field-valid", valid); + + if (!valid && hint) { + if (!existingHint) { + const hintEl = document.createElement("span"); + hintEl.className = "field-hint"; + hintEl.textContent = hint; + wrapper.appendChild(hintEl); + } else { + existingHint.textContent = hint; + } + } else if (existingHint) { + existingHint.remove(); + } +} + +/***************************************************************************** + * IP utilities + *****************************************************************************/ + +/** Returns the host part of a CIDR string, e.g. "10.0.0.1/24" -> "10.0.0.1". */ +function parseBaseIP(cidr) { + return cidr ? cidr.split("/")[0] : ""; +} + +/** Returns the prefix length of a CIDR string, defaulting to "24". */ +function parsePrefix(cidr) { + return cidr && cidr.includes("/") ? cidr.split("/")[1] : "24"; +} + +/** Converts a dotted-decimal IPv4 string to a 32-bit unsigned integer. */ +function ipToInt(ip) { + return ip.split(".").reduce((acc, oct) => (acc << 8) + parseInt(oct, 10), 0); +} + +/** Converts a 32-bit unsigned integer back to a dotted-decimal IPv4 string. */ +function intToIp(int) { + return [ + (int >>> 24) & 255, + (int >>> 16) & 255, + (int >>> 8) & 255, + int & 255, + ].join("."); +} + +/** + * Returns the network base address for a CIDR, e.g. "10.0.0.3/24" -> "10.0.0.0/24". + * Used to build the AllowedIPs line in client configs. + * + * @param {string} cidr - e.g. "10.0.0.1/24" + * @returns {string} - e.g. "10.0.0.0/24" + */ +function getNetworkAddress(cidr) { + const [ip, prefix] = cidr.split("/"); + const mask = prefix === "0" ? 0 : (~0 << (32 - +prefix)) >>> 0; + return intToIp((ipToInt(ip) & mask) >>> 0) + "/" + prefix; +} + +/** + * Derives the IP address to assign to a specific client. + * + * Clients are allocated sequentially from the server's own address. + * e.g. if the server is 10.0.0.1/24, client 1 gets 10.0.0.2/24, + * client 2 gets 10.0.0.3/24, and so on. + * + * @param {string} serverCIDR - Server address in CIDR notation. + * @param {number} clientIndex - 1-based index of the client. + * @returns {string} - Client CIDR, e.g. "10.0.0.2/24". + */ +function getClientIP(serverCIDR, clientIndex) { + const [ip] = serverCIDR.split("/"); + const prefix = parsePrefix(serverCIDR); + const parts = ip.split(".").map(Number); + parts[3] = parts[3] + clientIndex; + return parts.join(".") + "/" + prefix; +} + +/***************************************************************************** + * Config generators + *****************************************************************************/ + +/** + * Builds the complete wg0.conf text for the server side. + * + * Assembles the [Interface] block with all iptables PostUp/PostDown rules, + * then appends a [Peer] block for every client that has a public key set. + * Rules vary based on: NAT/internet forwarding (always on), LAN-between-clients + * flag, per-port DNAT forwards, and whether the server runs inside Docker + * (which changes the outbound interface used in iptables rules). + * + * @returns {string} Full .conf file content, or a placeholder comment if + * required fields are missing. + */ +function generateServerConf() { + const s = state.server; + if (!s.iface || !s.address || !s.port || !s.privateKey) { + return "# Fill in required server fields (Interface, Address, Port, Private Key) and click Sync"; + } + + // When running in Docker the host's physical interface is used for iptables + // rules rather than the WireGuard interface itself. + const outIface = s.docker && s.dockerIface ? s.dockerIface : s.iface; + const ports = s.allowedPorts + ? s.allowedPorts + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + : []; + const serverIP = parseBaseIP(s.address); + + let lines = []; + + lines.push("[Interface]"); + lines.push(`Address = ${s.address}`); + lines.push("SaveConfig = false"); + lines.push(""); + + lines.push("# Internet access for VPN clients"); + lines.push( + `PostUp = iptables -A FORWARD -i ${s.iface} -o ${outIface} -j ACCEPT`, + ); + lines.push( + `PostUp = iptables -A FORWARD -i ${outIface} -o ${s.iface} -m state --state RELATED,ESTABLISHED -j ACCEPT`, + ); + lines.push( + `PostUp = iptables -t nat -A POSTROUTING -o ${outIface} -j MASQUERADE`, + ); + + if (s.lanBetweenClients) { + lines.push(""); + lines.push("# Allow VPN clients to talk to each other"); + lines.push( + `PostUp = iptables -A FORWARD -i ${s.iface} -o ${s.iface} -j ACCEPT`, + ); + } + + ports.forEach((port) => { + lines.push(""); + lines.push(`# Port ${port} forward`); + lines.push( + `PostUp = iptables -t nat -A PREROUTING -i ${outIface} -p tcp --dport ${port} -j DNAT --to-destination ${serverIP}:${port}`, + ); + lines.push( + `PostUp = iptables -A FORWARD -i ${outIface} -o ${s.iface} -p tcp --dport ${port} -j ACCEPT`, + ); + }); + + lines.push(""); + lines.push("# Cleanup"); + lines.push( + `PostDown = iptables -D FORWARD -i ${s.iface} -o ${outIface} -j ACCEPT`, + ); + lines.push( + `PostDown = iptables -D FORWARD -i ${outIface} -o ${s.iface} -m state --state RELATED,ESTABLISHED -j ACCEPT`, + ); + lines.push( + `PostDown = iptables -t nat -D POSTROUTING -o ${outIface} -j MASQUERADE`, + ); + + if (s.lanBetweenClients) { + lines.push( + `PostDown = iptables -D FORWARD -i ${s.iface} -o ${s.iface} -j ACCEPT`, + ); + } + + ports.forEach((port) => { + lines.push( + `PostDown = iptables -t nat -D PREROUTING -i ${outIface} -p tcp --dport ${port} -j DNAT --to-destination ${serverIP}:${port}`, + ); + lines.push( + `PostDown = iptables -D FORWARD -i ${outIface} -o ${s.iface} -p tcp --dport ${port} -j ACCEPT`, + ); + }); + + lines.push(""); + lines.push(`ListenPort = ${s.port}`); + lines.push(`PrivateKey = ${s.privateKey}`); + + state.clients.forEach((client, idx) => { + if (!client.publicKey) return; + const clientAddr = getClientIP(s.address, idx + 1); + const clientIP = parseBaseIP(clientAddr); + lines.push(""); + lines.push(`# ${client.name || "Client " + (idx + 1)}`); + lines.push("[Peer]"); + lines.push(`PublicKey = ${client.publicKey}`); + lines.push(`AllowedIPs = ${clientIP}/32`); + lines.push( + `#Endpoint = ${s.publicAddress || ""}:${s.port}`, + ); + }); + + return lines.join("\n"); +} + +/** + * Builds the client-side .conf text for a single peer. + * + * The AllowedIPs line differs based on the client's internetTraffic flag: + * when true, 0.0.0.0/0 is included so all traffic routes through the VPN; + * when false, only the VPN subnet is routed (LAN-only access). + * + * @param {Object} client - Client object from state.clients. + * @param {number} clientIndex - 0-based index in state.clients, used to + * derive this client's VPN IP address. + * @returns {string} Full .conf file content. + */ +function generateClientConf(client, clientIndex) { + const s = state.server; + if (!client) return ""; + + const clientAddr = getClientIP(s.address, clientIndex + 1); + const networkBase = s.address ? getNetworkAddress(s.address) : "10.0.0.0/24"; + const allowedIPs = client.internetTraffic + ? `${networkBase}, 0.0.0.0/0` + : networkBase; + + let lines = []; + lines.push("# Client configuration"); + lines.push("[Interface]"); + lines.push(`Address = ${clientAddr || "/"}`); + lines.push("DNS = 1.1.1.1"); + lines.push(`PrivateKey = ${client.privateKey || ""}`); + lines.push(""); + lines.push("# Server configuration"); + lines.push("[Peer]"); + lines.push(`PublicKey = ${s.publicKey || ""}`); + lines.push(`AllowedIPs = ${allowedIPs}`); + lines.push( + `Endpoint = ${s.publicAddress || ""}:${s.port || ""}`, + ); + lines.push("PersistentKeepalive = 25"); + + return lines.join("\n"); +} + +/***************************************************************************** + * DOM helpers + *****************************************************************************/ + +/** Shorthand for document.getElementById. */ +function $(id) { + return document.getElementById(id); +} + +/** Reads all server form inputs into a plain object matching the state shape. */ +function getServerFormValues() { + return { + iface: $("s-iface").value.trim(), + address: $("s-address").value.trim(), + port: $("s-port").value.trim(), + lanBetweenClients: $("s-lan").checked, + allowedPorts: $("s-ports").value.trim(), + privateKey: $("s-privkey").value.trim(), + publicKey: $("s-pubkey").value.trim(), + publicAddress: $("s-pubaddr").value.trim(), + docker: $("s-docker").checked, + dockerIface: $("s-docker-iface").value.trim(), + }; +} + +/** + * Reads the currently visible client form inputs. + * Returns safe defaults when the form elements don't exist (no active client). + */ +function getActiveClientFormValues() { + return { + name: $("c-name") ? $("c-name").value.trim() : "", + internetTraffic: $("c-internet") ? $("c-internet").checked : true, + privateKey: $("c-privkey") ? $("c-privkey").value.trim() : "", + publicKey: $("c-pubkey") ? $("c-pubkey").value.trim() : "", + }; +} + +/** + * Writes server state back into all form inputs and updates toggle labels. + * Called after loading a JSON config file. + */ +function populateServerForm() { + const s = state.server; + $("s-iface").value = s.iface; + $("s-address").value = s.address; + $("s-port").value = s.port; + $("s-lan").checked = s.lanBetweenClients; + $("s-ports").value = s.allowedPorts; + $("s-privkey").value = s.privateKey; + $("s-pubkey").value = s.publicKey; + $("s-pubaddr").value = s.publicAddress; + $("s-docker").checked = s.docker; + $("s-docker-iface").value = s.dockerIface; + + const lanLabel = $("s-lan-label"); + if (lanLabel) + lanLabel.textContent = s.lanBetweenClients ? "Enabled" : "Disabled"; + const dockerLabel = $("s-docker-label"); + if (dockerLabel) dockerLabel.textContent = s.docker ? "Yes" : "No"; + + toggleDockerField(); +} + +/** Shows or hides the Docker host interface row based on the Docker checkbox. */ +function toggleDockerField() { + const dockerRow = $("docker-iface-row"); + if (dockerRow) { + dockerRow.style.display = $("s-docker").checked ? "flex" : "none"; + } +} + +/***************************************************************************** + * Client panel + *****************************************************************************/ + +/** + * Re-renders the client tab strip from state.clients. + * + * Creates one button per client. The active tab gets the .active class. + * Each tab includes an x delete button that stops click propagation so it + * doesn't also trigger the tab-switch handler. + */ +function renderClientTabs() { + const tabsEl = $("client-tabs"); + tabsEl.innerHTML = ""; + + state.clients.forEach((client) => { + const tab = document.createElement("button"); + tab.className = + "client-tab" + (client.id === activeClientId ? " active" : ""); + tab.textContent = client.name || "Client"; + tab.title = client.name; + tab.addEventListener("click", () => { + saveActiveClient(); + activeClientId = client.id; + renderClientTabs(); + renderClientForm(); + updateClientConf(); + }); + + const del = document.createElement("span"); + del.className = "tab-del"; + del.textContent = "x"; + del.title = "Remove client"; + del.addEventListener("click", (e) => { + e.stopPropagation(); + removeClient(client.id); + }); + + tab.appendChild(del); + tabsEl.appendChild(tab); + }); +} + +/** + * Renders the client form area for the currently active client. + * + * Injects HTML for all client fields, then wires up live validation listeners + * and the name-change handler that keeps the tab label in sync. Shows an + * empty-state placeholder when no client is selected. + */ +function renderClientForm() { + const formEl = $("client-form-area"); + + if (!activeClientId || state.clients.length === 0) { + formEl.innerHTML = `
+ + + +

Add a client to get started

+
`; + return; + } + + const client = state.clients.find((c) => c.id === activeClientId); + if (!client) return; + + formEl.innerHTML = ` +
+ + +
+
+ + + ${client.internetTraffic ? "Allowed" : "Blocked"} +
+
+ + +
+
+ + +
+ `; + + // Live validation for text fields + const cFields = { + "c-name": "name", + "c-privkey": "privateKey", + "c-pubkey": "publicKey", + }; + Object.entries(cFields).forEach(([elId, fieldKey]) => { + const el = $(elId); + if (!el) return; + el.addEventListener("input", () => { + const result = validateValue( + CLIENT_VALIDATORS, + fieldKey, + el.value.trim(), + ); + markField(el, result.valid, result.hint); + }); + }); + + // Keep the toggle label in sync with the checkbox + const internetToggle = $("c-internet"); + if (internetToggle) { + internetToggle.addEventListener("change", () => { + const label = internetToggle + .closest(".toggle-row") + .querySelector(".toggle-label"); + label.textContent = internetToggle.checked ? "Allowed" : "Blocked"; + }); + } + + // Reflect name changes immediately in the tab strip + const nameInput = $("c-name"); + if (nameInput) { + nameInput.addEventListener("input", () => { + const clientRef = state.clients.find((c) => c.id === activeClientId); + if (clientRef) { + clientRef.name = nameInput.value.trim() || "Client"; + renderClientTabs(); + } + }); + } +} + +/** Flushes the currently visible client form values into the matching state.clients entry. */ +function saveActiveClient() { + if (!activeClientId) return; + const client = state.clients.find((c) => c.id === activeClientId); + if (!client) return; + Object.assign(client, getActiveClientFormValues()); +} + +/** Creates a new client entry with defaults, appends it to state, and activates it. */ +function addClient() { + saveActiveClient(); + clientCounter++; + const newClient = { + id: "client-" + clientCounter, + name: "Client " + clientCounter, + internetTraffic: true, + privateKey: "", + publicKey: "", + }; + state.clients.push(newClient); + activeClientId = newClient.id; + renderClientTabs(); + renderClientForm(); + updateClientConf(); +} + +/** Removes a client by id, falls back the active selection, and redraws. */ +function removeClient(id) { + state.clients = state.clients.filter((c) => c.id !== id); + if (activeClientId === id) { + activeClientId = + state.clients.length > 0 + ? state.clients[state.clients.length - 1].id + : null; + } + renderClientTabs(); + renderClientForm(); + syncAll(); +} + +/***************************************************************************** + * Validation pass + *****************************************************************************/ + +/** + * Validates every server form field and marks each input accordingly. + * Maps element IDs to their validator key and current value, then delegates + * to validateValue + markField for each. + * + * @returns {boolean} True if all fields are valid. + */ +function validateServerForm() { + const s = getServerFormValues(); + let allValid = true; + + const fieldMap = { + "s-iface": ["iface", s.iface], + "s-address": ["address", s.address], + "s-port": ["port", s.port], + "s-ports": ["allowedPorts", s.allowedPorts], + "s-privkey": ["privateKey", s.privateKey], + "s-pubkey": ["publicKey", s.publicKey], + "s-pubaddr": ["publicAddress", s.publicAddress], + "s-docker-iface": ["dockerIface", s.dockerIface], + }; + + Object.entries(fieldMap).forEach(([elId, [fieldKey, value]]) => { + const el = $(elId); + if (!el) return; + const result = validateValue(SERVER_VALIDATORS, fieldKey, value); + markField(el, result.valid, result.hint); + if (!result.valid) allValid = false; + }); + + return allValid; +} + +/** + * Validates the active client's fields and marks each input accordingly. + * No-ops cleanly when no client is selected. + * + * @returns {boolean} True if all fields are valid (or no client is active). + */ +function validateActiveClientForm() { + if (!activeClientId) return true; + saveActiveClient(); + + const client = state.clients.find((c) => c.id === activeClientId); + if (!client) return true; + let allValid = true; + + const fieldMap = { + "c-name": ["name", client.name], + "c-privkey": ["privateKey", client.privateKey], + "c-pubkey": ["publicKey", client.publicKey], + }; + + Object.entries(fieldMap).forEach(([elId, [fieldKey, value]]) => { + const el = $(elId); + if (!el) return; + const result = validateValue(CLIENT_VALIDATORS, fieldKey, value); + markField(el, result.valid, result.hint); + if (!result.valid) allValid = false; + }); + + return allValid; +} + +/***************************************************************************** + * Sync and update + *****************************************************************************/ + +/** + * Main sync handler called by the Sync button. + * + * Commits form values to state, runs validation on both panels, regenerates + * both config outputs, and flashes the button green on success. + */ +function syncAll() { + state.server = getServerFormValues(); + saveActiveClient(); + + const serverOk = validateServerForm(); + validateActiveClientForm(); + + updateServerConf(); + updateClientConf(); + + const btn = $("sync-btn"); + if (serverOk) { + btn.classList.add("synced"); + setTimeout(() => btn.classList.remove("synced"), 1200); + } +} + +function updateServerConf() { + const el = $("server-conf-output"); + if (el) el.textContent = generateServerConf(); +} + +function updateClientConf() { + const el = $("client-conf-output"); + const titleEl = $("client-conf-name"); + if (!el) return; + + if (!activeClientId || state.clients.length === 0) { + el.textContent = "# No client selected"; + if (titleEl) titleEl.textContent = ""; + return; + } + + const idx = state.clients.findIndex((c) => c.id === activeClientId); + const client = state.clients[idx]; + el.textContent = generateClientConf(client, idx); + if (titleEl) titleEl.textContent = client.name || "Client"; +} + +/***************************************************************************** + * Copy, download, load + *****************************************************************************/ + +/** + * Copies the text content of a config output element to the clipboard and + * briefly updates the button label to give visual confirmation. + * + * @param {string} outputId - ID of the
 element containing the config.
+ * @param {string} btnId    - ID of the button to flash "Copied!".
+ */
+function copyConf(outputId, btnId) {
+  const text = $(outputId)?.textContent || "";
+  if (!text) return;
+  navigator.clipboard.writeText(text).then(() => {
+    const btn = $(btnId);
+    const orig = btn.textContent;
+    btn.textContent = "Copied!";
+    btn.classList.add("copied");
+    setTimeout(() => {
+      btn.textContent = orig;
+      btn.classList.remove("copied");
+    }, 1500);
+  });
+}
+
+/**
+ * Serialises the current state (server + all clients) to a timestamped JSON
+ * file and triggers a browser download.
+ */
+function downloadConfig() {
+  state.server = getServerFormValues();
+  saveActiveClient();
+
+  const payload = {
+    exportedAt: new Date().toISOString(),
+    server: state.server,
+    clients: state.clients,
+  };
+
+  const blob = new Blob([JSON.stringify(payload, null, 2)], {
+    type: "application/json",
+  });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement("a");
+  a.href = url;
+  a.download = `wireguard-config-${Date.now()}.json`;
+  a.click();
+  URL.revokeObjectURL(url);
+}
+
+/**
+ * Opens a file picker for a previously saved JSON config, parses it, merges
+ * it into state, and fully re-renders the UI. Shows a toast on success or
+ * failure.
+ */
+function loadConfigFromFile() {
+  const input = document.createElement("input");
+  input.type = "file";
+  input.accept = ".json,application/json";
+  input.addEventListener("change", (e) => {
+    const file = e.target.files[0];
+    if (!file) return;
+    const reader = new FileReader();
+    reader.onload = (ev) => {
+      try {
+        const data = JSON.parse(ev.target.result);
+        if (data.server) state.server = { ...state.server, ...data.server };
+        if (Array.isArray(data.clients)) state.clients = data.clients;
+        if (state.clients.length > 0) {
+          activeClientId = state.clients[state.clients.length - 1].id;
+          clientCounter = state.clients.length;
+        }
+        populateServerForm();
+        renderClientTabs();
+        renderClientForm();
+        syncAll();
+        showToast("Configuration loaded");
+      } catch {
+        showToast("Failed to parse JSON file", true);
+      }
+    };
+    reader.readAsText(file);
+  });
+  input.click();
+}
+
+/*****************************************************************************
+ * Toast
+ *****************************************************************************/
+
+/**
+ * Displays a transient notification at the bottom-right of the screen.
+ *
+ * @param {string}  msg       - Message to display.
+ * @param {boolean} [isError] - If true, renders with error styling.
+ */
+function showToast(msg, isError = false) {
+  const toast = document.createElement("div");
+  toast.className = "toast" + (isError ? " toast-error" : "");
+  toast.textContent = msg;
+  document.body.appendChild(toast);
+  requestAnimationFrame(() => toast.classList.add("toast-show"));
+  setTimeout(() => {
+    toast.classList.remove("toast-show");
+    setTimeout(() => toast.remove(), 300);
+  }, 2500);
+}
+
+/*****************************************************************************
+ * Utility
+ *****************************************************************************/
+
+/** Escapes a string for safe injection into HTML attribute values. */
+function escHtml(str) {
+  return String(str || "")
+    .replace(/&/g, "&")
+    .replace(/"/g, """)
+    .replace(//g, ">");
+}
+
+/*****************************************************************************
+ * Init
+ *****************************************************************************/
+
+/**
+ * Bootstraps the application once the DOM is ready.
+ *
+ * Wires up all event listeners: live validation for server inputs, toggle
+ * label updates, the Sync button, client add button, copy/download/load
+ * buttons. Then performs an initial render so the UI is in a consistent
+ * state before the user interacts.
+ */
+function init() {
+  // Map element ID -> validator key for all server text inputs
+  const serverFieldMap = {
+    "s-iface": "iface",
+    "s-address": "address",
+    "s-port": "port",
+    "s-ports": "allowedPorts",
+    "s-privkey": "privateKey",
+    "s-pubkey": "publicKey",
+    "s-pubaddr": "publicAddress",
+    "s-docker-iface": "dockerIface",
+  };
+
+  Object.entries(serverFieldMap).forEach(([elId, fieldKey]) => {
+    const el = $(elId);
+    if (!el) return;
+    el.addEventListener("input", () => {
+      const result = validateValue(
+        SERVER_VALIDATORS,
+        fieldKey,
+        el.value.trim(),
+      );
+      markField(el, result.valid, result.hint);
+    });
+  });
+
+  $("s-lan").addEventListener("change", () => {
+    const label = $("s-lan-label");
+    if (label) label.textContent = $("s-lan").checked ? "Enabled" : "Disabled";
+  });
+
+  $("s-docker").addEventListener("change", () => {
+    const label = $("s-docker-label");
+    if (label) label.textContent = $("s-docker").checked ? "Yes" : "No";
+    toggleDockerField();
+  });
+  toggleDockerField();
+
+  $("sync-btn").addEventListener("click", syncAll);
+  $("add-client-btn").addEventListener("click", addClient);
+  $("copy-server-btn").addEventListener("click", () =>
+    copyConf("server-conf-output", "copy-server-btn"),
+  );
+  $("copy-client-btn").addEventListener("click", () =>
+    copyConf("client-conf-output", "copy-client-btn"),
+  );
+  $("download-btn").addEventListener("click", downloadConfig);
+  $("load-btn").addEventListener("click", loadConfigFromFile);
+
+  renderClientTabs();
+  renderClientForm();
+  updateServerConf();
+  updateClientConf();
+}
+
+document.addEventListener("DOMContentLoaded", init);
diff --git a/src/gui/styles.css b/src/gui/styles.css
new file mode 100644
index 0000000..5e76aac
--- /dev/null
+++ b/src/gui/styles.css
@@ -0,0 +1,735 @@
+/* WireGuard GUI — styles.css
+   Soft sage-and-cream palette, Fira Mono for config output */
+
+/*****************************************************************************
+ * Custom properties
+ *****************************************************************************/
+
+:root {
+  /* Palette */
+  --sage-50: #f4f7f4;
+  --sage-100: #e8efe8;
+  --sage-200: #cfdecf;
+  --sage-300: #a8c5a8;
+  --sage-400: #7aa87a;
+  --sage-500: #5a8f5a;
+  --sage-600: #437043;
+
+  --cream-50: #fdfcf8;
+  --cream-100: #f9f6ee;
+  --cream-200: #f0ead8;
+
+  --slate-50: #f5f6f8;
+  --slate-100: #e8eaee;
+  --slate-200: #cdd1da;
+  --slate-400: #8b93a5;
+  --slate-600: #4a5568;
+  --slate-700: #374151;
+  --slate-800: #1f2937;
+  --slate-900: #111827;
+
+  --rose-100: #ffe4e6;
+  --rose-500: #f43f5e;
+  --rose-600: #e11d48;
+
+  --sky-100: #e0f2fe;
+  --sky-500: #0ea5e9;
+
+  /* Semantic */
+  --bg: var(--cream-50);
+  --bg-panel: #ffffff;
+  --bg-section: var(--sage-50);
+  --bg-output: var(--slate-800);
+
+  --border: var(--slate-100);
+  --border-mid: var(--slate-200);
+
+  --text-primary: var(--slate-800);
+  --text-secondary: var(--slate-600);
+  --text-muted: var(--slate-400);
+  --text-output: #d4e6c3;
+
+  --accent: var(--sage-500);
+  --accent-light: var(--sage-100);
+  --accent-hover: var(--sage-600);
+
+  --invalid-bg: var(--rose-100);
+  --invalid-border: var(--rose-500);
+  --invalid-text: var(--rose-600);
+
+  --valid-border: var(--sage-400);
+
+  /* Sizing */
+  --header-h: 52px;
+  --radius-sm: 6px;
+  --radius-md: 10px;
+  --radius-lg: 16px;
+
+  /* Typography */
+  --font-ui: "Mulish", system-ui, sans-serif;
+  --font-display: "Syne", system-ui, sans-serif;
+  --font-mono: "Fira Mono", "Fira Code", monospace;
+
+  /* Transitions */
+  --ease: cubic-bezier(0.25, 0.46, 0.45, 0.94);
+}
+
+/*****************************************************************************
+ * Reset
+ *****************************************************************************/
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+
+html,
+body {
+  height: 100%;
+  overflow: hidden;
+}
+
+body {
+  font-family: var(--font-ui);
+  background: var(--bg);
+  color: var(--text-primary);
+  font-size: 13px;
+  line-height: 1.5;
+  display: flex;
+  flex-direction: column;
+}
+
+/*****************************************************************************
+ * Header
+ *****************************************************************************/
+
+.app-header {
+  height: var(--header-h);
+  min-height: var(--header-h);
+  background: var(--bg-panel);
+  border-bottom: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 1.25rem;
+  gap: 1rem;
+  flex-shrink: 0;
+  -webkit-app-region: drag;
+}
+
+/* Buttons inside the draggable header must opt back out */
+.app-header > * {
+  -webkit-app-region: no-drag;
+}
+
+.app-header-left {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+}
+
+.logo-icon {
+  width: 22px;
+  height: 22px;
+  color: var(--accent);
+  flex-shrink: 0;
+}
+
+.app-title {
+  font-family: var(--font-display);
+  font-size: 15px;
+  font-weight: 700;
+  color: var(--slate-800);
+  letter-spacing: -0.02em;
+}
+
+.app-title em {
+  font-style: normal;
+  color: var(--accent);
+}
+
+.app-header-right {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+/*****************************************************************************
+ * Main layout
+ *****************************************************************************/
+
+.gui-layout {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: row;
+  overflow: hidden;
+  min-height: 0;
+}
+
+/*****************************************************************************
+ * Panels
+ *****************************************************************************/
+
+.panel {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  overflow: hidden;
+  border-right: 1px solid var(--border);
+}
+
+.panel:last-child {
+  border-right: none;
+}
+
+.panel-header {
+  padding: 0.9rem 1.25rem 0.7rem;
+  border-bottom: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  background: var(--bg-panel);
+  flex-shrink: 0;
+}
+
+.panel-header h2 {
+  font-family: var(--font-display);
+  font-size: 13px;
+  font-weight: 600;
+  letter-spacing: 0.01em;
+  color: var(--text-secondary);
+}
+
+.panel-badge {
+  font-family: var(--font-mono);
+  font-size: 9px;
+  font-weight: 500;
+  letter-spacing: 0.1em;
+  padding: 2px 7px;
+  border-radius: 4px;
+}
+
+.badge-server {
+  background: var(--sage-100);
+  color: var(--sage-600);
+  border: 1px solid var(--sage-200);
+}
+
+.badge-client {
+  background: var(--sky-100);
+  color: var(--sky-500);
+  border: 1px solid #bae6fd;
+}
+
+.panel-body {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  min-height: 0;
+}
+
+/*****************************************************************************
+ * Form section
+ *****************************************************************************/
+
+.form-section {
+  flex: 0 0 auto;
+  padding: 1rem 1.25rem 0.5rem;
+  background: var(--bg-panel);
+  overflow-y: auto;
+  max-height: 46%;
+  border-bottom: 1px solid var(--border);
+}
+
+.section-label {
+  font-family: var(--font-display);
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  color: var(--text-muted);
+  margin-bottom: 0.5rem;
+}
+
+/*****************************************************************************
+ * Fields
+ *****************************************************************************/
+
+.field-row {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+  margin-bottom: 0.55rem;
+  position: relative;
+}
+
+.field-row label {
+  font-size: 11.5px;
+  font-weight: 600;
+  color: var(--text-secondary);
+  letter-spacing: 0.01em;
+}
+
+.field-row input[type="text"],
+.field-row input[type="password"] {
+  width: 100%;
+  height: 30px;
+  padding: 0 0.6rem;
+  border: 1.5px solid var(--border-mid);
+  border-radius: var(--radius-sm);
+  background: var(--bg);
+  color: var(--text-primary);
+  font-family: var(--font-ui);
+  font-size: 12.5px;
+  transition:
+    border-color 0.15s var(--ease),
+    box-shadow 0.15s var(--ease),
+    background 0.15s;
+  outline: none;
+}
+
+.field-row input:focus {
+  border-color: var(--accent);
+  box-shadow: 0 0 0 3px rgba(90, 143, 90, 0.12);
+  background: #fff;
+}
+
+.mono-input {
+  font-family: var(--font-mono) !important;
+  font-size: 11.5px !important;
+}
+
+.key-input {
+  font-size: 10.5px !important;
+  letter-spacing: 0.02em;
+}
+
+/* Validation states */
+.field-invalid {
+  border-color: var(--invalid-border) !important;
+  background: var(--invalid-bg) !important;
+}
+
+.field-invalid:focus {
+  box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.12) !important;
+}
+
+.field-valid {
+  border-color: var(--valid-border) !important;
+}
+
+.field-hint {
+  font-size: 10.5px;
+  color: var(--invalid-text);
+  font-weight: 500;
+  display: block;
+  margin-top: 1px;
+}
+
+.field-sub {
+  font-size: 10px;
+  color: var(--text-muted);
+  margin-top: 1px;
+}
+
+/*****************************************************************************
+ * Toggle switch
+ *****************************************************************************/
+
+.toggle-row {
+  flex-direction: row !important;
+  align-items: center;
+  gap: 8px !important;
+}
+
+.toggle-row label:first-child {
+  flex: 1;
+}
+
+.toggle-switch {
+  position: relative;
+  display: inline-block;
+  width: 34px;
+  height: 18px;
+  flex-shrink: 0;
+}
+
+.toggle-switch input {
+  opacity: 0;
+  width: 0;
+  height: 0;
+  position: absolute;
+}
+
+.toggle-slider {
+  position: absolute;
+  inset: 0;
+  background: var(--slate-200);
+  border-radius: 999px;
+  cursor: pointer;
+  transition: background 0.2s var(--ease);
+}
+
+.toggle-slider::before {
+  content: "";
+  position: absolute;
+  height: 12px;
+  width: 12px;
+  left: 3px;
+  top: 3px;
+  border-radius: 50%;
+  background: white;
+  transition: transform 0.2s var(--ease);
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+}
+
+.toggle-switch input:checked + .toggle-slider {
+  background: var(--accent);
+}
+
+.toggle-switch input:checked + .toggle-slider::before {
+  transform: translateX(16px);
+}
+
+.toggle-label {
+  font-size: 11px;
+  color: var(--text-muted);
+  font-weight: 500;
+  min-width: 45px;
+}
+
+/*****************************************************************************
+ * Config output
+ *****************************************************************************/
+
+.conf-output-section {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  background: var(--bg-output);
+}
+
+.conf-output-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.45rem 0.9rem;
+  background: rgba(255, 255, 255, 0.05);
+  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+  flex-shrink: 0;
+}
+
+.conf-output-title {
+  font-family: var(--font-mono);
+  font-size: 10.5px;
+  color: rgba(255, 255, 255, 0.45);
+  letter-spacing: 0.05em;
+}
+
+.conf-client-name {
+  font-family: var(--font-mono);
+  font-size: 10.5px;
+  color: rgba(255, 255, 255, 0.7);
+  letter-spacing: 0.04em;
+  margin-left: auto;
+  margin-right: 0.6rem;
+}
+
+.conf-output {
+  flex: 1 1 0;
+  overflow-y: auto;
+  overflow-x: auto;
+  padding: 0.9rem 1rem;
+  margin: 0;
+  font-family: var(--font-mono);
+  font-size: 11.5px;
+  line-height: 1.7;
+  color: var(--text-output);
+  white-space: pre;
+  background: transparent;
+  border: none;
+  outline: none;
+}
+
+.conf-output::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+.conf-output::-webkit-scrollbar-track {
+  background: transparent;
+}
+.conf-output::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.12);
+  border-radius: 999px;
+}
+
+.form-section::-webkit-scrollbar {
+  width: 4px;
+}
+.form-section::-webkit-scrollbar-track {
+  background: transparent;
+}
+.form-section::-webkit-scrollbar-thumb {
+  background: var(--slate-200);
+  border-radius: 999px;
+}
+
+/*****************************************************************************
+ * Client tabs
+ *****************************************************************************/
+
+.client-tabs-bar {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.6rem 1.25rem 0;
+  background: var(--bg-panel);
+  border-bottom: 1px solid var(--border);
+  flex-shrink: 0;
+  flex-wrap: wrap;
+}
+
+.client-tabs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.3rem;
+  flex: 1;
+}
+
+.client-tab {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
+  border: 1.5px solid var(--border-mid);
+  border-bottom: none;
+  background: var(--bg);
+  color: var(--text-secondary);
+  font-family: var(--font-ui);
+  font-size: 11.5px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.15s var(--ease);
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: -1px;
+}
+
+.client-tab:hover {
+  background: var(--sage-50);
+  color: var(--accent);
+}
+
+.client-tab.active {
+  background: var(--bg-panel);
+  color: var(--accent);
+  border-color: var(--sage-300);
+  border-bottom-color: var(--bg-panel);
+  z-index: 1;
+}
+
+.tab-del {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 14px;
+  height: 14px;
+  border-radius: 50%;
+  font-size: 13px;
+  line-height: 1;
+  color: var(--text-muted);
+  flex-shrink: 0;
+  margin-left: 2px;
+  transition:
+    background 0.1s,
+    color 0.1s;
+}
+
+.tab-del:hover {
+  background: var(--rose-100);
+  color: var(--rose-600);
+}
+
+/*****************************************************************************
+ * Buttons
+ *****************************************************************************/
+
+.btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 6px 12px;
+  border-radius: var(--radius-sm);
+  border: 1.5px solid transparent;
+  font-family: var(--font-ui);
+  font-size: 12px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.15s var(--ease);
+  letter-spacing: 0.01em;
+  white-space: nowrap;
+}
+
+.btn svg {
+  width: 13px;
+  height: 13px;
+  flex-shrink: 0;
+}
+
+.btn-primary {
+  background: var(--accent);
+  color: white;
+  border-color: var(--accent);
+}
+
+.btn-primary:hover {
+  background: var(--accent-hover);
+  border-color: var(--accent-hover);
+  transform: translateY(-1px);
+  box-shadow: 0 3px 8px rgba(90, 143, 90, 0.3);
+}
+
+.btn-primary:active {
+  transform: translateY(0);
+}
+
+.btn-primary.synced {
+  background: var(--sage-400);
+  animation: pulse-green 0.4s var(--ease);
+}
+
+@keyframes pulse-green {
+  0% {
+    box-shadow: 0 0 0 0 rgba(90, 143, 90, 0.5);
+  }
+  100% {
+    box-shadow: 0 0 0 8px rgba(90, 143, 90, 0);
+  }
+}
+
+.btn-ghost {
+  background: transparent;
+  color: var(--text-secondary);
+  border-color: var(--border-mid);
+}
+
+.btn-ghost:hover {
+  background: var(--sage-50);
+  color: var(--accent);
+  border-color: var(--sage-300);
+}
+
+.btn-ghost.copied {
+  color: var(--accent);
+  border-color: var(--sage-300);
+  background: var(--sage-50);
+}
+
+.btn-sm {
+  padding: 3px 9px;
+  font-size: 11px;
+}
+
+.btn-add {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: var(--radius-sm);
+  border: 1.5px dashed var(--sage-300);
+  background: transparent;
+  color: var(--accent);
+  font-family: var(--font-ui);
+  font-size: 11.5px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.15s var(--ease);
+  white-space: nowrap;
+}
+
+.btn-add svg {
+  width: 13px;
+  height: 13px;
+}
+
+.btn-add:hover {
+  background: var(--sage-50);
+  border-color: var(--accent);
+  transform: translateY(-1px);
+}
+
+/*****************************************************************************
+ * Empty state
+ *****************************************************************************/
+
+.empty-client {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 0.75rem;
+  padding: 2rem;
+  color: var(--text-muted);
+  text-align: center;
+  flex: 1;
+}
+
+.empty-client svg {
+  opacity: 0.4;
+  color: var(--sage-400);
+}
+
+.empty-client p {
+  font-size: 12.5px;
+  color: var(--text-muted);
+}
+
+/*****************************************************************************
+ * Toast
+ *****************************************************************************/
+
+.toast {
+  position: fixed;
+  bottom: 1.5rem;
+  right: 1.5rem;
+  background: var(--slate-800);
+  color: white;
+  padding: 0.65rem 1.1rem;
+  border-radius: var(--radius-md);
+  font-size: 12.5px;
+  font-weight: 500;
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
+  opacity: 0;
+  transform: translateY(8px);
+  transition:
+    opacity 0.25s var(--ease),
+    transform 0.25s var(--ease);
+  z-index: 1000;
+}
+
+.toast-show {
+  opacity: 1;
+  transform: translateY(0);
+}
+
+.toast-error {
+  background: var(--rose-600);
+}
+
+/*****************************************************************************
+ * Scrollbars (global Firefox fallback)
+ *****************************************************************************/
+
+* {
+  scrollbar-width: thin;
+  scrollbar-color: var(--slate-200) transparent;
+}
diff --git a/index.html b/src/index.html
similarity index 90%
rename from index.html
rename to src/index.html
index 4a39eea..1585d04 100644
--- a/index.html
+++ b/src/index.html
@@ -16,5 +16,6 @@
   
     

Trying out electron rendering for Wireguard GUI!

👋

+

CLICK

diff --git a/main.js b/src/main.js similarity index 92% rename from main.js rename to src/main.js index a40f5cd..3e25cf6 100644 --- a/main.js +++ b/src/main.js @@ -6,7 +6,7 @@ const createWindow = () => { height: 600, }); - win.loadFile("index.html"); + win.loadFile("src/gui/gui.html"); }; app.whenReady().then(() => { diff --git a/wireguard-icon.svg b/wireguard-icon.svg new file mode 100644 index 0000000..81823b3 --- /dev/null +++ b/wireguard-icon.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file