From 3480c75f4c032479383f252afef0e94801db8b02 Mon Sep 17 00:00:00 2001 From: SRS IT Date: Sun, 29 Mar 2026 16:27:03 -0400 Subject: [PATCH] feat: add macOS build, Docker image, and CI workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS (.dmg): - Add mac build config to apps/desktop/package.json (x64 + arm64 DMGs) - .github/workflows/build-mac.yml — builds on macos-latest, uploads to release Docker (headless scheduler + web remote): - docker/server.js — Node.js entry point using homebridge-plugin lib; REST API + WebSocket, serves mobile web UI on PORT (default 3456) - docker/package.json — production deps (adm-zip, axios, sql.js, ws, xml2js, xmlbuilder2) - Dockerfile — node:20-alpine image; VOLUME /data for persistent config - .github/workflows/build-docker.yml — builds linux/amd64+arm64, pushes to ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager Usage: docker run -d --network host -v /opt/wemo:/data \ ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager:latest Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build-docker.yml | 43 ++++++ .github/workflows/build-mac.yml | 52 +++++++ Dockerfile | 36 +++++ apps/desktop/package.json | 10 ++ docker/package.json | 14 ++ docker/server.js | 218 +++++++++++++++++++++++++++++ 6 files changed, 373 insertions(+) create mode 100644 .github/workflows/build-docker.yml create mode 100644 .github/workflows/build-mac.yml create mode 100644 Dockerfile create mode 100644 docker/package.json create mode 100644 docker/server.js diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml new file mode 100644 index 0000000..68b5565 --- /dev/null +++ b/.github/workflows/build-docker.yml @@ -0,0 +1,43 @@ +name: Build & Push Docker Image + +on: + workflow_dispatch: + inputs: + tag: + description: 'Docker image tag (e.g. 2.0.0)' + required: true + default: '2.0.0' + +jobs: + build-docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager:${{ github.event.inputs.tag }} + ghcr.io/k0rb3nd4ll4s/dibby-wemo-manager:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/build-mac.yml b/.github/workflows/build-mac.yml new file mode 100644 index 0000000..9f69076 --- /dev/null +++ b/.github/workflows/build-mac.yml @@ -0,0 +1,52 @@ +name: Build & Upload macOS Package + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to upload assets to (e.g. v2.0.0)' + required: true + default: 'v2.0.0' + +jobs: + build-mac: + runs-on: macos-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install workspace dependencies + run: npm install --legacy-peer-deps + + - name: Vite build + run: npx electron-vite build + working-directory: apps/desktop + + - name: Bundle standalone scheduler + run: node scripts/bundle-standalone.js + working-directory: apps/desktop + + - name: Build macOS packages + run: npx electron-builder --mac --publish never + working-directory: apps/desktop + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + + - name: List build output + run: ls -lh apps/desktop/dist/ + + - name: Upload macOS packages to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag }} + files: apps/desktop/dist/*.dmg + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..592b678 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM node:20-alpine + +LABEL maintainer="SRS IT" \ + org.opencontainers.image.title="Dibby Wemo Manager" \ + org.opencontainers.image.description="Headless Belkin Wemo scheduler + web remote — no cloud required" \ + org.opencontainers.image.source="https://github.com/K0rb3nD4ll4S/dibby-wemo-manager" + +WORKDIR /app + +# Copy package manifest first for layer caching +COPY docker/package.json ./package.json + +# Install production dependencies +RUN npm install --production + +# Copy application code +COPY packages/homebridge-plugin/lib ./lib +COPY docker/server.js ./server.js + +# Copy mobile web UI and icon +COPY apps/desktop/resources/web ./web +COPY apps/desktop/resources/icon.png ./icon.png + +# Persistent data volume (stores dibby-wemo.json config + rules) +VOLUME /data + +ENV DATA_DIR=/data \ + PORT=3456 + +EXPOSE 3456 + +# NOTE: Wemo SSDP discovery requires --network host on Linux Docker hosts. +# On macOS Docker Desktop, host networking is not supported — add devices +# manually via the web UI or the REST API instead. + +CMD ["node", "server.js"] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index da81a2a..5652712 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,6 +45,16 @@ "directories": { "output": "dist" }, + "mac": { + "target": [ + { "target": "dmg", "arch": ["x64", "arm64"] } + ], + "icon": "resources/icon.png", + "category": "public.app-category.utilities" + }, + "dmg": { + "title": "Dibby Wemo Manager" + }, "win": { "target": [ { diff --git a/docker/package.json b/docker/package.json new file mode 100644 index 0000000..171feeb --- /dev/null +++ b/docker/package.json @@ -0,0 +1,14 @@ +{ + "name": "dibby-wemo-docker", + "version": "2.0.0", + "description": "Dibby Wemo Manager — headless scheduler + web remote", + "main": "server.js", + "dependencies": { + "adm-zip": "^0.5.14", + "axios": "^1.7.0", + "sql.js": "^1.12.0", + "ws": "^8.18.0", + "xml2js": "^0.6.2", + "xmlbuilder2": "^4.0.3" + } +} diff --git a/docker/server.js b/docker/server.js new file mode 100644 index 0000000..a360b5a --- /dev/null +++ b/docker/server.js @@ -0,0 +1,218 @@ +'use strict'; + +/** + * Dibby Wemo Manager — Docker entry point + * + * Runs the DWM scheduler + REST/WebSocket API server without Electron. + * Configure via environment variables: + * DATA_DIR — path to persistent data directory (default: /data) + * PORT — HTTP port (default: 3456) + */ + +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const DwmStore = require('./lib/store'); +const DwmScheduler = require('./lib/scheduler'); +const wemo = require('./lib/wemo-client'); + +const DATA_DIR = process.env.DATA_DIR || '/data'; +const PORT = parseInt(process.env.PORT || '3456', 10); +const WEB_DIR = path.join(__dirname, 'web'); + +// ── Bootstrap ───────────────────────────────────────────────────────────────── + +const store = new DwmStore(DATA_DIR); +const scheduler = new DwmScheduler({ store, wemoClient: wemo, log: console }); + +let _wss = null; + +function broadcast(type, data) { + if (!_wss) return; + const msg = JSON.stringify({ type, data }); + for (const client of _wss.clients) { + if (client.readyState === 1 /* OPEN */) client.send(msg); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getLocalIP() { + for (const iface of Object.values(os.networkInterfaces())) { + for (const addr of iface) { + if (addr.family === 'IPv4' && !addr.internal) return addr.address; + } + } + return 'localhost'; +} + +function json(res, data, status = 200) { + const body = JSON.stringify(data); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }); + res.end(body); +} + +function jsonErr(res, msg, status = 500) { + json(res, { error: msg }, status); +} + +// ── Request router ──────────────────────────────────────────────────────────── + +async function handleRequest(req, res) { + const url = req.url.split('?')[0]; + const method = req.method.toUpperCase(); + + if (method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE', + 'Access-Control-Allow-Headers': 'Content-Type', + }); + res.end(); + return; + } + + const body = await new Promise((resolve) => { + if (method !== 'POST' && method !== 'PUT') return resolve({}); + let raw = ''; + req.on('data', (c) => { raw += c; }); + req.on('end', () => { try { resolve(JSON.parse(raw)); } catch { resolve({}); } }); + }); + + try { + // ── Devices ──────────────────────────────────────────────────────────── + + if (url === '/api/devices' && method === 'GET') { + return json(res, store.getDevices()); + } + + if (url === '/api/devices/discover' && method === 'POST') { + const saved = store.getDevices(); + const manual = saved.map((d) => ({ host: d.host, port: d.port })); + const devs = await wemo.discoverDevices(8000, manual); + store.saveDevices(devs); + return json(res, devs); + } + + const stateMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/state$/); + if (stateMatch) { + const [, host, port] = stateMatch; + if (method === 'GET') { + const on = await wemo.getBinaryState(host, Number(port)); + return json(res, { on }); + } + if (method === 'POST') { + await wemo.setBinaryState(host, Number(port), !!body.on); + return json(res, { ok: true }); + } + } + + // ── DWM Rules ────────────────────────────────────────────────────────── + + if (url === '/api/dwm-rules') { + if (method === 'GET') return json(res, store.getDwmRules()); + if (method === 'POST') { + const rule = store.createDwmRule(body); + scheduler.reload(); + return json(res, rule, 201); + } + } + + const ruleMatch = url.match(/^\/api\/dwm-rules\/(.+)$/); + if (ruleMatch) { + const id = ruleMatch[1]; + if (method === 'PUT') { + const updated = store.updateDwmRule(id, body); + scheduler.reload(); + return json(res, updated); + } + if (method === 'DELETE') { + store.deleteDwmRule(id); + scheduler.reload(); + return json(res, { ok: true }); + } + } + + // ── Wemo Device Rules ────────────────────────────────────────────────── + + const wemoRulesMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/rules$/); + if (wemoRulesMatch && method === 'GET') { + const [, host, port] = wemoRulesMatch; + return json(res, await wemo.getRules(host, Number(port))); + } + + const wemoRuleMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/rules\/(\d+)$/); + if (wemoRuleMatch && method === 'PUT') { + const [, host, port, ruleId] = wemoRuleMatch; + await wemo.updateRule(host, Number(port), Number(ruleId), body); + return json(res, { ok: true }); + } + + // ── Scheduler ────────────────────────────────────────────────────────── + + if (url === '/api/scheduler/status' && method === 'GET') { + return json(res, scheduler.getStatus()); + } + + // ── Static assets ────────────────────────────────────────────────────── + + if (url === '/icon.png' && method === 'GET') { + const file = path.join(__dirname, 'icon.png'); + fs.readFile(file, (e, data) => { + if (e) { res.writeHead(404); res.end(); return; } + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(data); + }); + return; + } + + // Fallback → serve mobile web UI + const file = path.join(WEB_DIR, 'index.html'); + fs.readFile(file, (e, data) => { + if (e) { res.writeHead(404); res.end('Web UI not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(data); + }); + + } catch (e) { + jsonErr(res, e.message); + } +} + +// ── Start ───────────────────────────────────────────────────────────────────── + +async function main() { + // Ensure data directory exists + fs.mkdirSync(DATA_DIR, { recursive: true }); + + await scheduler.start(); + console.log(`[DWM] Scheduler started — data: ${DATA_DIR}`); + + const server = http.createServer(handleRequest); + + let WebSocketServer; + try { WebSocketServer = require('ws').WebSocketServer || require('ws').Server; } catch {} + + if (WebSocketServer) { + _wss = new WebSocketServer({ server }); + _wss.on('connection', (ws) => { + ws.send(JSON.stringify({ type: 'scheduler-status', data: scheduler.getStatus() })); + }); + scheduler.onFire = (event) => broadcast('scheduler-fired', event); + scheduler.onStatus = (status) => broadcast('scheduler-status', status); + } + + server.listen(PORT, '0.0.0.0', () => { + console.log(`[DWM] Web Remote: http://${getLocalIP()}:${PORT}`); + }); + + process.on('SIGTERM', () => { server.close(); process.exit(0); }); + process.on('SIGINT', () => { server.close(); process.exit(0); }); +} + +main().catch((e) => { console.error('[DWM] Fatal:', e); process.exit(1); });