diff --git a/frontend/src/js/raw.js b/frontend/src/js/raw.js index 93eaf52..77b74b7 100644 --- a/frontend/src/js/raw.js +++ b/frontend/src/js/raw.js @@ -236,7 +236,11 @@ class RawViewer { } try { - const b64 = btoa(dataToSend); + // For hex/escaped, data is already raw bytes (0-255) so btoa is correct. + // For text mode, UTF-8 encode so non-ASCII characters are transmitted properly. + const b64 = (format === 'hex' || format === 'escaped') + ? btoa(dataToSend) + : window.NexusTerminal.utf8ToBase64(dataToSend); await window.go.api.Service.WriteSession(sessionId, b64); input.value = ''; diff --git a/frontend/src/js/snippets.js b/frontend/src/js/snippets.js index 0a177dc..837c9c1 100644 --- a/frontend/src/js/snippets.js +++ b/frontend/src/js/snippets.js @@ -91,7 +91,7 @@ /** @private */ _sendCommand(sessionId, command) { try { - const b64 = btoa(command + "\n"); + const b64 = window.NexusTerminal.utf8ToBase64(command + "\n"); window.go.api.Service.WriteSession(sessionId, b64); this.app.notifications.info("Snippet sent"); } catch (err) { diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index 4fbb47c..3ef6a9f 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -7,6 +7,20 @@ /* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */ + /** + * Encode a string as UTF-8 bytes then base64-encode them. + * Unlike bare btoa(), this handles characters above U+007F correctly + * (e.g. degree symbol °, emoji) and won't throw InvalidCharacterError. + */ + function utf8ToBase64(str) { + const bytes = new TextEncoder().encode(str); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + class TerminalManager { /** * @param {App} app @@ -119,25 +133,33 @@ // scrollback content in alternate mode). const viewportEl = container.querySelector(".xterm-viewport"); if (viewportEl) { - viewportEl.addEventListener("wheel", (e) => { - if (term.buffer.active !== term.buffer.alternate) return; - // Only intercept when mouse tracking is NOT active (if mouse mode - // is on, xterm.js already forwards events correctly). - if (term.modes && term.modes.mouseTrackingMode && - term.modes.mouseTrackingMode !== "none") return; + viewportEl.addEventListener( + "wheel", + (e) => { + if (term.buffer.active !== term.buffer.alternate) return; + // Only intercept when mouse tracking is NOT active (if mouse mode + // is on, xterm.js already forwards events correctly). + if ( + term.modes && + term.modes.mouseTrackingMode && + term.modes.mouseTrackingMode !== "none" + ) + return; - e.preventDefault(); - e.stopPropagation(); - const lines = Math.max(1, Math.round(Math.abs(e.deltaY) / 40)); - const seq = e.deltaY < 0 ? "\x1b[A" : "\x1b[B"; - const data = seq.repeat(lines); - const b64 = btoa(data); - try { - window.go.api.Service.WriteSession(sessionId, b64); - } catch (_) { - /* non-critical */ - } - }, { passive: false }); + e.preventDefault(); + e.stopPropagation(); + const lines = Math.max(1, Math.round(Math.abs(e.deltaY) / 40)); + const seq = e.deltaY < 0 ? "\x1b[A" : "\x1b[B"; + const data = seq.repeat(lines); + const b64 = utf8ToBase64(data); + try { + window.go.api.Service.WriteSession(sessionId, b64); + } catch (_) { + /* non-critical */ + } + }, + { passive: false }, + ); } // Drag-and-drop file handling @@ -177,7 +199,7 @@ // Forward user keystrokes to the backend term.onData((data) => { try { - const b64 = btoa(data); + const b64 = utf8ToBase64(data); window.go.api.Service.WriteSession(sessionId, b64); // Broadcast to other connected sessions if enabled diff --git a/frontend/src/js/utils.js b/frontend/src/js/utils.js index 0c66aa7..3afaaaa 100644 --- a/frontend/src/js/utils.js +++ b/frontend/src/js/utils.js @@ -163,6 +163,21 @@ return btoa(str); } + /** + * UTF-8-encodes a string and then base64-encodes the bytes. + * Unlike bare btoa(), this handles characters above U+007F correctly. + * @param {string} str + * @returns {string} + */ + function utf8ToBase64(str) { + const bytes = new TextEncoder().encode(str); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + /** * Base64-decodes a string. * @param {string} str @@ -295,6 +310,7 @@ debounce, fuzzyMatch, base64Encode, + utf8ToBase64, base64Decode, generateId, formatBytes,