@mjasano/devtunnel 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,10 @@
17
17
  "Bash(git commit:*)",
18
18
  "Bash(git push:*)",
19
19
  "Bash(gh release create:*)",
20
- "Bash(PORT=3099 node:*)"
20
+ "Bash(PORT=3099 node:*)",
21
+ "Bash(/tmp/devtunnel-test/node_modules/.bin/devtunnel:*)",
22
+ "Bash(lsof:*)",
23
+ "Bash(kill:*)"
21
24
  ]
22
25
  }
23
26
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.2] - 2026-01-02
9
+
10
+ ### Fixed
11
+ - Korean (Hangul) IME input on mobile - use dedicated input field for proper composition handling
12
+
13
+ ## [1.5.1] - 2026-01-02
14
+
15
+ ### Fixed
16
+ - Korean (Hangul) IME input on mobile devices - characters no longer split into individual jamo
17
+
8
18
  ## [1.5.0] - 2026-01-02
9
19
 
10
20
  ### Added
package/bin/cli.js CHANGED
@@ -117,9 +117,18 @@ function startServer(port, passcode = null) {
117
117
  detached: false
118
118
  });
119
119
 
120
+ // Track the actual port the server is running on
121
+ server.actualPort = port;
122
+
120
123
  server.stdout.on('data', (data) => {
121
124
  const msg = data.toString().trim();
122
125
  if (msg) log(`${c.dim}[server] ${msg}${c.reset}`);
126
+
127
+ // Parse actual port from server output
128
+ const portMatch = msg.match(/running on http:\/\/localhost:(\d+)/);
129
+ if (portMatch) {
130
+ server.actualPort = parseInt(portMatch[1]);
131
+ }
123
132
  });
124
133
 
125
134
  server.stderr.on('data', (data) => {
@@ -237,8 +246,14 @@ async function main() {
237
246
  log(`${c.dim}Starting server on port ${port}...${c.reset}`);
238
247
  const server = startServer(port, passcode);
239
248
 
249
+ // Wait a bit for server to output its actual port
250
+ await new Promise(resolve => setTimeout(resolve, 1000));
251
+
240
252
  try {
241
- await waitForServer(port);
253
+ // Use the actual port the server is running on
254
+ const actualPort = server.actualPort;
255
+ await waitForServer(actualPort);
256
+ port = actualPort; // Update port for tunnel
242
257
  log(`${c.green}✓${c.reset} Server running`);
243
258
  } catch (err) {
244
259
  log(`${c.red}✗ Failed to start server: ${err.message}${c.reset}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasano/devtunnel",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Web terminal with code editor and tunnel manager - access your dev environment from anywhere",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -110,24 +110,110 @@ require(['vs/editor/editor.main'], function() {
110
110
  document.getElementById('monaco-editor').style.display = 'none';
111
111
  });
112
112
 
113
- // Tab switching
114
- function switchTab(tab) {
115
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
116
- document.querySelector(`.tab[data-tab="${tab}"]`).classList.add('active');
113
+ // View mode state
114
+ let currentViewMode = 'split'; // 'terminal', 'editor', 'split'
115
+
116
+ // View mode switching
117
+ function setViewMode(mode) {
118
+ currentViewMode = mode;
117
119
 
120
+ // Update button states
121
+ document.querySelectorAll('.view-btn').forEach(btn => btn.classList.remove('active'));
122
+ const activeBtn = document.querySelector(`.view-btn[data-view="${mode}"]`);
123
+ if (activeBtn) activeBtn.classList.add('active');
124
+
125
+ const mainContainer = document.querySelector('.main-container');
118
126
  const terminalContainer = document.getElementById('terminal-container');
119
127
  const editorContainer = document.getElementById('editor-container');
120
128
 
121
- if (tab === 'terminal') {
129
+ // Reset flex styles
130
+ terminalContainer.style.flex = '';
131
+ editorContainer.style.flex = '';
132
+
133
+ if (mode === 'split') {
134
+ // Split view - show both
135
+ mainContainer.classList.add('split-view');
136
+ terminalContainer.classList.remove('hidden');
137
+ editorContainer.classList.add('active');
138
+ setTimeout(() => fitAddon.fit(), 0);
139
+ } else if (mode === 'terminal') {
140
+ // Terminal only
141
+ mainContainer.classList.remove('split-view');
122
142
  terminalContainer.classList.remove('hidden');
123
143
  editorContainer.classList.remove('active');
124
144
  setTimeout(() => fitAddon.fit(), 0);
125
145
  term.focus();
126
146
  } else {
147
+ // Editor only
148
+ mainContainer.classList.remove('split-view');
127
149
  terminalContainer.classList.add('hidden');
128
150
  editorContainer.classList.add('active');
129
151
  if (monacoEditor) monacoEditor.focus();
130
152
  }
153
+
154
+ // Save preference
155
+ localStorage.setItem('devtunnel-view-mode', mode);
156
+ }
157
+
158
+ // Legacy switchTab for compatibility
159
+ function switchTab(tab) {
160
+ if (tab === 'terminal') {
161
+ setViewMode('terminal');
162
+ } else {
163
+ setViewMode('editor');
164
+ }
165
+ }
166
+
167
+ // Panel resizer functionality
168
+ function initPanelResizer() {
169
+ const resizer = document.getElementById('panel-resizer');
170
+ const terminalContainer = document.getElementById('terminal-container');
171
+ const editorContainer = document.getElementById('editor-container');
172
+
173
+ if (!resizer) return;
174
+
175
+ let startX, startTerminalWidth, startEditorWidth;
176
+
177
+ resizer.addEventListener('mousedown', (e) => {
178
+ e.preventDefault();
179
+ startX = e.clientX;
180
+ const terminalRect = terminalContainer.getBoundingClientRect();
181
+ const editorRect = editorContainer.getBoundingClientRect();
182
+ startTerminalWidth = terminalRect.width;
183
+ startEditorWidth = editorRect.width;
184
+
185
+ document.body.classList.add('resizing');
186
+ resizer.classList.add('dragging');
187
+
188
+ document.addEventListener('mousemove', onMouseMove);
189
+ document.addEventListener('mouseup', onMouseUp);
190
+ });
191
+
192
+ function onMouseMove(e) {
193
+ const dx = e.clientX - startX;
194
+ const totalWidth = startTerminalWidth + startEditorWidth;
195
+ const minWidth = 200;
196
+ const newTerminalWidth = Math.max(minWidth, Math.min(totalWidth - minWidth, startTerminalWidth + dx));
197
+ const newEditorWidth = totalWidth - newTerminalWidth;
198
+
199
+ terminalContainer.style.flex = `0 0 ${newTerminalWidth}px`;
200
+ editorContainer.style.flex = `0 0 ${newEditorWidth}px`;
201
+
202
+ fitAddon.fit();
203
+ }
204
+
205
+ function onMouseUp() {
206
+ document.body.classList.remove('resizing');
207
+ resizer.classList.remove('dragging');
208
+ document.removeEventListener('mousemove', onMouseMove);
209
+ document.removeEventListener('mouseup', onMouseUp);
210
+
211
+ // Save panel sizes
212
+ const terminalRect = terminalContainer.getBoundingClientRect();
213
+ const editorRect = editorContainer.getBoundingClientRect();
214
+ const total = terminalRect.width + editorRect.width;
215
+ localStorage.setItem('devtunnel-panel-ratio', (terminalRect.width / total).toFixed(3));
216
+ }
131
217
  }
132
218
 
133
219
  // File browser functions
@@ -227,7 +313,10 @@ function navigateToPath(path) {
227
313
 
228
314
  // File operations
229
315
  async function openFile(path) {
230
- switchTab('editor');
316
+ // In terminal-only mode, switch to split view to show editor
317
+ if (currentViewMode === 'terminal') {
318
+ setViewMode('split');
319
+ }
231
320
 
232
321
  if (openFiles.has(path)) {
233
322
  activateFile(path);
@@ -488,6 +577,7 @@ function promptRename(path) {
488
577
 
489
578
  // Export functions
490
579
  window.switchTab = switchTab;
580
+ window.setViewMode = setViewMode;
491
581
  window.navigateToPath = navigateToPath;
492
582
  window.openFile = openFile;
493
583
  window.activateFile = activateFile;
@@ -539,6 +629,100 @@ term.loadAddon(webLinksAddon);
539
629
  term.open(terminalContainer);
540
630
  fitAddon.fit();
541
631
 
632
+ // 모바일 IME 한글 입력 처리
633
+ let isComposing = false;
634
+ let isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
635
+
636
+ // 모바일용 숨겨진 입력 필드 생성
637
+ const mobileInput = document.createElement('input');
638
+ mobileInput.type = 'text';
639
+ mobileInput.autocapitalize = 'off';
640
+ mobileInput.autocomplete = 'off';
641
+ mobileInput.autocorrect = 'off';
642
+ mobileInput.spellcheck = false;
643
+ mobileInput.style.cssText = `
644
+ position: absolute;
645
+ left: -9999px;
646
+ top: 0;
647
+ width: 1px;
648
+ height: 1px;
649
+ opacity: 0;
650
+ z-index: -1;
651
+ `;
652
+ terminalContainer.appendChild(mobileInput);
653
+
654
+ mobileInput.addEventListener('compositionstart', () => {
655
+ isComposing = true;
656
+ });
657
+
658
+ mobileInput.addEventListener('compositionend', (e) => {
659
+ isComposing = false;
660
+ if (e.data && ws && ws.readyState === WebSocket.OPEN) {
661
+ ws.send(JSON.stringify({ type: 'input', data: e.data }));
662
+ }
663
+ mobileInput.value = '';
664
+ });
665
+
666
+ mobileInput.addEventListener('input', (e) => {
667
+ // 조합 중이 아닐 때만 전송 (영문, 숫자 등)
668
+ if (!isComposing && e.data && ws && ws.readyState === WebSocket.OPEN) {
669
+ ws.send(JSON.stringify({ type: 'input', data: e.data }));
670
+ mobileInput.value = '';
671
+ }
672
+ });
673
+
674
+ mobileInput.addEventListener('keydown', (e) => {
675
+ // 특수키 처리 (Enter, Backspace, Arrow 등)
676
+ if (!isComposing) {
677
+ let data = null;
678
+ switch (e.key) {
679
+ case 'Enter': data = '\r'; break;
680
+ case 'Backspace': data = '\x7f'; break;
681
+ case 'Tab': data = '\t'; e.preventDefault(); break;
682
+ case 'Escape': data = '\x1b'; break;
683
+ case 'ArrowUp': data = '\x1b[A'; break;
684
+ case 'ArrowDown': data = '\x1b[B'; break;
685
+ case 'ArrowRight': data = '\x1b[C'; break;
686
+ case 'ArrowLeft': data = '\x1b[D'; break;
687
+ }
688
+ if (data && ws && ws.readyState === WebSocket.OPEN) {
689
+ ws.send(JSON.stringify({ type: 'input', data }));
690
+ if (e.key !== 'Tab') mobileInput.value = '';
691
+ }
692
+ }
693
+ });
694
+
695
+ // 모바일에서 터미널 클릭 시 숨겨진 입력 필드에 포커스
696
+ if (isMobile) {
697
+ terminalContainer.addEventListener('click', () => {
698
+ mobileInput.focus();
699
+ });
700
+ }
701
+
702
+ // Initialize panel resizer
703
+ initPanelResizer();
704
+
705
+ // Initialize view mode (default to split, or restore from localStorage)
706
+ const savedViewMode = localStorage.getItem('devtunnel-view-mode') || 'split';
707
+ setViewMode(savedViewMode);
708
+
709
+ // Restore panel ratio if saved
710
+ const savedRatio = localStorage.getItem('devtunnel-panel-ratio');
711
+ if (savedRatio && savedViewMode === 'split') {
712
+ setTimeout(() => {
713
+ const terminalContainer = document.getElementById('terminal-container');
714
+ const editorContainer = document.getElementById('editor-container');
715
+ const sidebar = document.querySelector('.sidebar');
716
+ const mainContainer = document.querySelector('.main-container');
717
+ const totalWidth = mainContainer.clientWidth - sidebar.clientWidth;
718
+ const terminalWidth = totalWidth * parseFloat(savedRatio);
719
+ const editorWidth = totalWidth - terminalWidth;
720
+ terminalContainer.style.flex = `0 0 ${terminalWidth}px`;
721
+ editorContainer.style.flex = `0 0 ${editorWidth}px`;
722
+ fitAddon.fit();
723
+ }, 100);
724
+ }
725
+
542
726
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
543
727
  const wsUrl = `${protocol}//${window.location.host}`;
544
728
  let ws;
@@ -613,7 +797,11 @@ function attachToTmux(sessionName) {
613
797
  localStorage.removeItem('devtunnel-session-id');
614
798
  term.clear();
615
799
  ws.send(JSON.stringify({ type: 'attach-tmux', tmuxSession: sessionName }));
616
- switchTab('terminal');
800
+ // In editor-only mode, switch to split view to show terminal
801
+ if (currentViewMode === 'editor') {
802
+ setViewMode('split');
803
+ }
804
+ term.focus();
617
805
  }
618
806
 
619
807
  function refreshTmuxSessions() {
@@ -688,7 +876,11 @@ function createNewSession() {
688
876
  localStorage.removeItem('devtunnel-session-id');
689
877
  term.clear();
690
878
  ws.send(JSON.stringify({ type: 'attach', sessionId: null }));
691
- switchTab('terminal');
879
+ // In editor-only mode, switch to split view to show terminal
880
+ if (currentViewMode === 'editor') {
881
+ setViewMode('split');
882
+ }
883
+ term.focus();
692
884
  }
693
885
 
694
886
  function attachToSession(sessionId) {
@@ -700,7 +892,11 @@ function attachToSession(sessionId) {
700
892
  localStorage.setItem('devtunnel-session-id', sessionId);
701
893
  term.clear();
702
894
  ws.send(JSON.stringify({ type: 'attach', sessionId }));
703
- switchTab('terminal');
895
+ // In editor-only mode, switch to split view to show terminal
896
+ if (currentViewMode === 'editor') {
897
+ setViewMode('split');
898
+ }
899
+ term.focus();
704
900
  }
705
901
 
706
902
  function killSession(sessionId) {
@@ -819,6 +1015,9 @@ function connect() {
819
1015
  }
820
1016
 
821
1017
  term.onData((data) => {
1018
+ // 모바일에서는 mobileInput으로 처리
1019
+ if (isMobile) return;
1020
+
822
1021
  if (ws && ws.readyState === WebSocket.OPEN) {
823
1022
  ws.send(JSON.stringify({ type: 'input', data }));
824
1023
  }
package/public/index.html CHANGED
@@ -31,16 +31,20 @@
31
31
  </div>
32
32
  </div>
33
33
 
34
- <!-- Tab Bar -->
35
- <div class="tab-bar">
36
- <div class="tab active" data-tab="terminal" onclick="switchTab('terminal')">
37
- <span class="tab-icon">></span>
34
+ <!-- View Controls -->
35
+ <div class="view-controls">
36
+ <button class="view-btn" data-view="terminal" onclick="setViewMode('terminal')">
37
+ <span class="view-icon">></span>
38
38
  Terminal
39
- </div>
40
- <div class="tab" data-tab="editor" onclick="switchTab('editor')">
41
- <span class="tab-icon">{}</span>
39
+ </button>
40
+ <button class="view-btn active" data-view="split" onclick="setViewMode('split')">
41
+ <span class="view-icon">||</span>
42
+ Split
43
+ </button>
44
+ <button class="view-btn" data-view="editor" onclick="setViewMode('editor')">
45
+ <span class="view-icon">{}</span>
42
46
  Editor
43
- </div>
47
+ </button>
44
48
  </div>
45
49
 
46
50
  <div class="sidebar-overlay" id="sidebar-overlay" onclick="toggleMobileSidebar()"></div>
@@ -52,6 +56,8 @@
52
56
  <div id="terminal"></div>
53
57
  </div>
54
58
 
59
+ <div class="panel-resizer" id="panel-resizer"></div>
60
+
55
61
  <div id="editor-container">
56
62
  <div class="editor-tabs" id="editor-tabs"></div>
57
63
  <div id="monaco-editor"></div>
package/public/styles.css CHANGED
@@ -444,18 +444,21 @@ body {
444
444
  border-color: #f85149;
445
445
  }
446
446
 
447
- /* Tab Bar */
448
- .tab-bar {
447
+ /* View Controls */
448
+ .view-controls {
449
449
  display: flex;
450
450
  background-color: #161b22;
451
451
  border-bottom: 1px solid #30363d;
452
452
  padding: 0 12px;
453
+ gap: 4px;
453
454
  }
454
455
 
455
- .tab {
456
- padding: 10px 20px;
456
+ .view-btn {
457
+ padding: 8px 16px;
457
458
  font-size: 13px;
458
459
  color: #8b949e;
460
+ background-color: transparent;
461
+ border: none;
459
462
  cursor: pointer;
460
463
  border-bottom: 2px solid transparent;
461
464
  transition: all 0.15s ease;
@@ -464,18 +467,55 @@ body {
464
467
  gap: 8px;
465
468
  }
466
469
 
467
- .tab:hover {
470
+ .view-btn:hover {
468
471
  color: #c9d1d9;
469
472
  background-color: #21262d;
470
473
  }
471
474
 
472
- .tab.active {
475
+ .view-btn.active {
473
476
  color: #c9d1d9;
474
477
  border-bottom-color: #58a6ff;
475
478
  }
476
479
 
477
- .tab-icon {
480
+ .view-icon {
478
481
  font-size: 14px;
482
+ font-family: monospace;
483
+ }
484
+
485
+ /* Panel Resizer */
486
+ .panel-resizer {
487
+ width: 4px;
488
+ background-color: #30363d;
489
+ cursor: col-resize;
490
+ flex-shrink: 0;
491
+ display: none;
492
+ transition: background-color 0.15s ease;
493
+ }
494
+
495
+ .panel-resizer:hover,
496
+ .panel-resizer.dragging {
497
+ background-color: #58a6ff;
498
+ }
499
+
500
+ body.resizing {
501
+ cursor: col-resize !important;
502
+ user-select: none;
503
+ }
504
+
505
+ body.resizing * {
506
+ cursor: col-resize !important;
507
+ }
508
+
509
+ /* Split View Mode */
510
+ .main-container.split-view .panel-resizer {
511
+ display: block;
512
+ }
513
+
514
+ .main-container.split-view #terminal-container,
515
+ .main-container.split-view #editor-container {
516
+ flex: 1;
517
+ display: flex;
518
+ flex-direction: column;
479
519
  }
480
520
 
481
521
  /* Editor Container */
@@ -937,13 +977,13 @@ body {
937
977
  display: flex;
938
978
  }
939
979
 
940
- .tab-bar {
980
+ .view-controls {
941
981
  padding: 0;
942
982
  overflow-x: auto;
943
983
  -webkit-overflow-scrolling: touch;
944
984
  }
945
985
 
946
- .tab {
986
+ .view-btn {
947
987
  flex: 1;
948
988
  padding: 12px 16px;
949
989
  font-size: 13px;
@@ -951,6 +991,19 @@ body {
951
991
  min-height: 44px;
952
992
  }
953
993
 
994
+ /* Disable split view on mobile */
995
+ .main-container.split-view .panel-resizer {
996
+ display: none;
997
+ }
998
+
999
+ .main-container.split-view #editor-container {
1000
+ display: none;
1001
+ }
1002
+
1003
+ .main-container.split-view #terminal-container {
1004
+ flex: 1;
1005
+ }
1006
+
954
1007
  .main-container {
955
1008
  flex-direction: column;
956
1009
  position: relative;
@@ -1197,11 +1250,11 @@ body {
1197
1250
  font-size: 11px;
1198
1251
  }
1199
1252
 
1200
- .tab-icon {
1253
+ .view-icon {
1201
1254
  display: none;
1202
1255
  }
1203
1256
 
1204
- .tab {
1257
+ .view-btn {
1205
1258
  font-size: 12px;
1206
1259
  }
1207
1260
 
package/server.js CHANGED
@@ -1104,7 +1104,7 @@ app.get('/health', (req, res) => {
1104
1104
  });
1105
1105
  });
1106
1106
 
1107
- const DEFAULT_PORT = process.env.PORT || 3000;
1107
+ const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
1108
1108
 
1109
1109
  function findAvailablePort(startPort, maxAttempts = 10) {
1110
1110
  return new Promise((resolve, reject) => {