class TimemasterAction extends ActionBase { constructor($UD, context) { super($UD, context); this.config = { uri: '', username: '', password: '', refreshRate: '3' }; this.ws = null; this.tickTimer = null; this.pingTimer = null; this.reconnectTimer = null; this.connected = false; this.handshakeDone = false; this.dataReceived = false; this.isError = false; this.errorText = null; this.noServer = false; this.balance = null; this.accessToken = null; this.refreshToken = null; this.tokenExpiry = 0; this.kommt = null; this.geht = null; this.$UD.onDidReceiveSettings(jsn => { if (jsn.context !== this.context) return; const s = jsn.settings || {}; const prevKey = this.connectionKey(); this.config = Object.assign(this.config, s); this.startRefresh(); if (this.connectionKey() !== prevKey) { this.accessToken = null; this.refreshToken = null; this.reconnect(); } }); } connectionKey() { return `${this.config.uri}|${this.config.username}`; } setActive() {} setParams(jsn) { this.config = Object.assign(this.config, (jsn && jsn.param) || {}); this.$UD.getSettings(this.context); this.startTick(); this.startRefresh(); this.reconnect(); } onRun() { if (!this.connected) { this.reconnect(); } else { this.bookingToggle(); } } onClear() { super.onClear(); this.stopTick(); this.stopRefresh(); this.stopPing(); this.cancelReconnect(); this.disconnectWs(); } startRefresh() { this.stopRefresh(); const duration = this.getRefreshDuration(this.config.refreshRate); if (duration > 0) { this.refreshTimer = setInterval(() => this.reconnect(), duration * 60 * 1000); } } stopRefresh() { if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; } } startTick() { this.stopTick(); this.tickTimer = setInterval(() => this.render(), 1000); } stopTick() { if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } } startPing() { this.stopPing(); this.pingTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN && this.handshakeDone) { this.ws.send(JSON.stringify({ type: 6 }) + '\x1e'); } }, 15000); } stopPing() { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } cancelReconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } disconnectWs() { this.stopPing(); this.cancelReconnect(); if (this.ws) { this.ws.onclose = null; this.ws.close(); this.ws = null; } this.connected = false; this.handshakeDone = false; this.dataReceived = false; } host() { let u = (this.config.uri || '').trim(); u = u.replace(/^wss?:\/\//, '').replace(/^https?:\/\//, ''); return u.split('/')[0]; } tokenUrl() { return `https://${this.host()}/TimemasterBackend/api/authentication/token`; } wsUrl() { return `wss://${this.host()}/TimemasterBackend/tm-hub`; } bookingUrl() { return `https://${this.host()}/TimemasterBackend/api/bookings/do`; } async reconnect() { this.disconnectWs(); if (!this.config.uri || !this.config.username) { this.render(); return; } try { await this.ensureToken(); this.openWs(); } catch (e) { this.$UD.logMessage(`[Timemaster] login error: ${e.message}`, 'error'); this.isError = true; this.errorText = e.message; this.render(); this.reconnectTimer = setTimeout(() => this.reconnect(), 15000); } } async ensureToken() { const now = Date.now() / 1000; if (this.accessToken && this.tokenExpiry > now + 60) return; // Try refresh token first if available if (this.refreshToken) { try { await this.refreshAccessToken(); return; } catch (e) { this.refreshToken = null; } } // Full login via XHR (avoids CORS preflight issues in Electron) const json = await this.xhrPost(this.tokenUrl(), { Client: 'timemaster', ClientSecret: '*G-KaPdSgVkYp3s6v9y$B?E(H+MbQeTh', Username: this.config.username, Password: this.config.password, }); await this.applyTokenResponse(json); } async refreshAccessToken() { const json = await this.xhrPost(this.tokenUrl(), { Client: 'timemaster', ClientSecret: '*G-KaPdSgVkYp3s6v9y$B?E(H+MbQeTh', RefreshToken: this.refreshToken, }); await this.applyTokenResponse(json); } xhrPost(url, body, extraHeaders = {}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.withCredentials = true; xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Accept', 'application/json'); for (const [k, v] of Object.entries(extraHeaders)) xhr.setRequestHeader(k, v); xhr.onload = () => { console.log(`[Timemaster] POST ${url} → ${xhr.status}`, xhr.responseText.slice(0, 200)); if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(new Error('Invalid JSON response')); } } else { reject(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = () => { console.error(`[Timemaster] POST ${url} → network error`); this.noServer = true; reject(new Error('Network error')); }; xhr.send(JSON.stringify(body)); }); } async applyTokenResponse(json) { if (!json.access_token) throw new Error('No token in response'); this.accessToken = json.access_token; this.refreshToken = json.refresh_token || null; this.tokenExpiry = Date.now() / 1000 + (json.expires_in || 1800); this.isError = false; this.errorText = null; this.noServer = false; } openWs() { try { const wsUri = `${this.wsUrl()}?access_token=${encodeURIComponent(this.accessToken)}`; this.ws = new WebSocket(wsUri); this.ws.onopen = () => { this.isError = false; this.errorText = null; // SignalR JSON protocol handshake this.ws.send(JSON.stringify({ protocol: 'json', version: 1 }) + '\x1e'); this.render(); }; this.ws.onmessage = (event) => { const parts = event.data.split('\x1e').filter(Boolean); for (const part of parts) { try { this.handleSignalRMessage(JSON.parse(part)); } catch (e) { this.$UD.logMessage(`[Timemaster] parse: ${e.message}`, 'error'); } } this.render(); }; this.ws.onerror = () => { this.isError = true; this.noServer = true; this.connected = false; this.render(); }; this.ws.onclose = () => { this.connected = false; this.handshakeDone = false; this.stopPing(); this.render(); this.reconnectTimer = setTimeout(() => this.reconnect(), 5000); }; this.render(); } catch (e) { this.$UD.logMessage(`[Timemaster] ws open: ${e.message}`, 'error'); this.isError = true; this.errorText = e.message; this.ws = null; this.render(); } } handleSignalRMessage(msg) { if (!this.handshakeDone) { this.handshakeDone = true; this.connected = true; this.startPing(); return; } if (msg.type === 6) return; if (msg.type === 7) { if (this.ws) this.ws.close(); return; } if (msg.type === 1 && Array.isArray(msg.arguments)) { if (msg.target === 'Info.UpdateLastWorkingHours') { this.dataReceived = true; for (const arg of msg.arguments) { this.parseWorkingHours(arg); } } else if (msg.target === 'Info.UpdateTimeAccountInformation') { for (const arg of msg.arguments) { if (arg && arg.Balance !== undefined) { this.balance = this.parseBalance(arg.Balance); } } } } } parseWorkingHours(data) { if (!data || typeof data !== 'object') return; const day = data.CurrentWorkingDay; if (!day || !Array.isArray(day.BookingPairs) || day.BookingPairs.length === 0) { this.kommt = null; this.geht = null; this.pairs = []; return; } const pairs = day.BookingPairs; this.pairs = pairs.map(p => ({ start: p.First ? this.parseTime(p.First.CalculationTimestamp) : null, end: p.Second ? this.parseTime(p.Second.CalculationTimestamp) : null, })); this.kommt = this.pairs[0].start; this.geht = this.pairs[this.pairs.length - 1].end; } parseBalance(value) { if (!value) return null; const neg = value.startsWith('-'); const s = neg ? value.slice(1) : value; // Format: "HH:MM:SS" or "D.HH:MM:SS" const m = s.match(/^(?:(\d+)\.)?(\d+):(\d+)/); if (!m) return null; const days = parseInt(m[1] || '0'); const hours = parseInt(m[2]) + days * 24; const mins = parseInt(m[3]); const total = hours * 60 + mins; return neg ? -total : total; } formatBalance(minutes) { const abs = Math.abs(minutes); const h = Math.floor(abs / 60); const m = Math.floor(abs % 60); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } parseTime(value) { if (!value) return null; const d = new Date(value); return isNaN(d.getTime()) ? null : d; } async bookingToggle() { if (!this.accessToken) return; // kommt set + geht not set → currently clocked in → send Geht (stop, type 2) // otherwise → send Kommt (start, type 9) const bookingType = (this.kommt && !this.geht) ? 2 : 9; try { await this.ensureToken(); await this.xhrPost( this.bookingUrl(), { BookingType: bookingType, CalculationTimestamp: new Date().toISOString() }, { Authorization: `Bearer ${this.accessToken}` } ); console.log(`[Timemaster] booking sent type=${bookingType}`); } catch (e) { console.error(`[Timemaster] booking error: ${e.message}`); } } elapsedMinutes() { if (!this.pairs || this.pairs.length === 0) return null; const now = new Date(); let total = 0; for (const p of this.pairs) { if (!p.start) continue; const end = p.end || now; total += Math.max(0, end - p.start); } return total / 60000; } formatHHMM(minutes) { const h = Math.floor(minutes / 60); const m = Math.floor(minutes % 60); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } formatTime(date) { if (!date) return '--:--'; return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } render() { const { canvas, ctx } = this.createCanvas(); const cx = 98, cy = 96; const r = 68; const lw = 14; const MAX_MIN = 8 * 60; const startAngle = 135 * Math.PI / 180; const sweep = 270 * Math.PI / 180; const endAngle = startAngle + sweep; // Background track ctx.beginPath(); ctx.arc(cx, cy, r, startAngle, endAngle, false); ctx.strokeStyle = '#333333'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke(); const minutes = this.elapsedMinutes(); if (this.connected && minutes !== null) { const pct = Math.min(1, minutes / MAX_MIN); const green6h = 6 / 8; const yellow75h = 7.5 / 8; const valueAngle = startAngle + pct * sweep; const greenEnd = startAngle + green6h * sweep; const yellowEnd = startAngle + yellow75h * sweep; if (pct > 0) { ctx.beginPath(); ctx.arc(cx, cy, r, startAngle, Math.min(valueAngle, greenEnd), false); ctx.strokeStyle = '#f44336'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke(); } if (pct > green6h) { ctx.beginPath(); ctx.arc(cx, cy, r, greenEnd, Math.min(valueAngle, yellowEnd), false); ctx.strokeStyle = '#ffeb3b'; ctx.lineWidth = lw; ctx.lineCap = 'butt'; ctx.stroke(); } if (pct > yellow75h) { ctx.beginPath(); ctx.arc(cx, cy, r, yellowEnd, valueAngle, false); ctx.strokeStyle = '#4caf50'; ctx.lineWidth = lw; ctx.lineCap = 'butt'; ctx.stroke(); } const working = !this.geht; const color = pct <= green6h ? '#f44336' : pct <= yellow75h ? '#ffeb3b' : '#4caf50'; ctx.fillStyle = working ? color : '#888888'; ctx.font = 'bold 44px "Source Han Sans SC"'; ctx.fillText(this.formatHHMM(minutes), cx, cy); // Time balance if (this.balance !== null) { ctx.fillStyle = this.balance >= 0 ? '#4caf50' : '#f44336'; ctx.font = '20px "Source Han Sans SC"'; ctx.fillText(this.formatBalance(this.balance), cx, cy + 34); } } else if (!this.connected) { const missing = !this.config.uri || !this.config.username; if (this.noServer) { ctx.fillStyle = '#ff6b6b'; ctx.font = 'bold 22px "Source Han Sans SC"'; ctx.fillText(this.$UD.t('VPN?'), cx, cy - 10); ctx.fillStyle = '#888888'; ctx.font = '16px "Source Han Sans SC"'; ctx.fillText(this.$UD.t('No Host'), cx, cy + 16); } else { ctx.fillStyle = this.isError ? '#ff6b6b' : '#666666'; ctx.font = `bold ${this.isError ? '18' : '22'}px "Source Han Sans SC"`; ctx.fillText( this.isError ? (this.errorText || 'ERR').slice(0, 16) : missing ? this.$UD.t('Not configured') : this.$UD.t('Connecting…'), cx, cy ); } } else if (this.dataReceived) { ctx.fillStyle = '#666666'; ctx.font = 'bold 44px "Source Han Sans SC"'; ctx.fillText('00:00', cx, cy); } else { ctx.fillStyle = '#444444'; ctx.font = 'bold 22px "Source Han Sans SC"'; ctx.fillText(this.$UD.t('Waiting…'), cx, cy); } // Bottom label ctx.fillStyle = '#7ec8e3'; ctx.font = 'bold 18px "Source Han Sans SC"'; ctx.fillText('Timemaster', cx, 174); this.setIcon(canvas); } }