1import { Controller } from "@hotwired/stimulus" 2import { createConsumer } from "@rails/actioncable" 3import { Terminal } from "@xterm/xterm" 4import { FitAddon } from "@xterm/addon-fit" 5 6export default class extends Controller { 7 static targets = ["terminal", "status", "successBanner"] 8 static values = { stationId: String, command: String, stationUrl: String } 9 10 // Patterns that indicate WhatsApp has successfully paired 11 static SUCCESS_PATTERNS = [ 12 "successfully", 13 "connected", 14 "paired", 15 "logged in", 16 "session saved", 17 "ready" 18 ] 19 20 connect() { 21 this.connected = false 22 this.outputBuffer = "" 23 this.setupTerminal() 24 this.connectChannel() 25 this.boundResize = () => this.fitTerminal() 26 window.addEventListener("resize", this.boundResize) 27 } 28 29 disconnect() { 30 window.removeEventListener("resize", this.boundResize) 31 this.subscription?.unsubscribe() 32 this.term?.dispose() 33 } 34 35 setupTerminal() { 36 this.term = new Terminal({ 37 cursorBlink: true, 38 fontSize: 14, 39 fontFamily: "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace", 40 theme: { 41 background: "#1e1e2e", 42 foreground: "#cdd6f4", 43 cursor: "#f5e0dc", 44 selectionBackground: "#585b70" 45 } 46 }) 47 48 this.fitAddon = new FitAddon() 49 this.term.loadAddon(this.fitAddon) 50 this.term.open(this.terminalTarget) 51 this.fitTerminal() 52 53 this.term.onData(data => { 54 this.subscription?.send({ input: data }) 55 }) 56 57 this.term.onResize(({ rows, cols }) => { 58 this.subscription?.send({ resize: { rows, cols } }) 59 }) 60 } 61 62 fitTerminal() { 63 try { 64 this.fitAddon?.fit() 65 // WhatsApp pairs render a QR roughly 41-43 cols wide. xterm.js's 66 // FitAddon undercounts by 1 col when the viewport scrollbar takes 67 // pixel width â shaving the rightmost column off and wrapping the 68 // QR's last block to a new line, breaking scannability. Enforce a 69 // minimum cols (and rows) so the PTY is always wide enough. 70 const MIN_COLS = 80 71 const MIN_ROWS = 24 72 if (this.term && (this.term.cols < MIN_COLS || this.term.rows < MIN_ROWS)) { 73 this.term.resize( 74 Math.max(this.term.cols, MIN_COLS), 75 Math.max(this.term.rows, MIN_ROWS) 76 ) 77 } 78 } catch (_e) { 79 // ignore fit errors during setup 80 } 81 } 82 83 connectChannel() { 84 if (this.hasStatusTarget) { 85 this.statusTarget.textContent = "Connecting..." 86 } 87 88 const consumer = createConsumer() 89 const controller = this 90 91 this.subscription = consumer.subscriptions.create( 92 { channel: "TerminalChannel", station_id: this.stationIdValue, command: this.commandValue }, 93 { 94 connected() { 95 if (controller.hasStatusTarget) { 96 controller.statusTarget.textContent = "Connected" 97 setTimeout(() => { controller.statusTarget.style.display = "none" }, 1500) 98 } 99 controller.fitTerminal() 100 // Explicitly resync PTY size to the backend. setupTerminal() runs 101 // fit + resize BEFORE this.subscription exists, so the onResize 102 // signal that should size the backend PTY gets dropped. Without 103 // this, the server PTY stays at its default size and the QR wraps 104 // even though xterm itself is wide enough to show it intact. 105 if (controller.term) { 106 controller.subscription?.send({ 107 resize: { rows: controller.term.rows, cols: controller.term.cols } 108 }) 109 } 110 }, 111 112 disconnected() { 113 controller.term?.write("\r\n\x1b[33m[Disconnected]\x1b[0m\r\n") 114 }, 115 116 rejected() { 117 controller.term?.write("\r\n\x1b[31m[Connection rejected â station may not be running]\x1b[0m\r\n") 118 }, 119 120 received(data) { 121 if (data.output) { 122 controller.term.write(data.output) 123 controller.checkForSuccess(data.output) 124 } 125 } 126 } 127 ) 128 } 129 130 checkForSuccess(output) { 131 if (this.connected) return 132 133 // Buffer recent output to catch multi-chunk messages 134 this.outputBuffer += output 135 // Keep buffer manageable â only check the last 500 chars 136 if (this.outputBuffer.length > 500) { 137 this.outputBuffer = this.outputBuffer.slice(-500) 138 } 139 140 const lower = this.outputBuffer.toLowerCase() 141 const matched = this.constructor.SUCCESS_PATTERNS.some(p => lower.includes(p)) 142 if (matched) { 143 this.showSuccess() 144 } 145 } 146 147 markConnected() { 148 // Manual fallback button action 149 this.showSuccess() 150 } 151 152 showSuccess() { 153 if (this.connected) return 154 this.connected = true 155 156 if (this.hasSuccessBannerTarget) { 157 this.successBannerTarget.style.display = "flex" 158 } 159 } 160}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.