diff --git a/docs/ipad-input-session-diagnostics-20260928.md b/docs/ipad-input-session-diagnostics-20260928.md index 7f82c80..f55dc56 100644 --- a/docs/ipad-input-session-diagnostics-20260928.md +++ b/docs/ipad-input-session-diagnostics-20260928.md @@ -8,10 +8,27 @@ vault: 미설정 문서 원본은 이 파일이다. 지정 Obsidian Vault 연결은 미설정이다. 요구사항·진행 상태: [이슈 #20](https://github.com/Jaeman-Lee/dotfiles/issues/20). +최종 기록·입력 개선 검토: [draft PR #23](https://github.com/Jaeman-Lee/dotfiles/pull/23). +초기 진단 기록: [커밋 fb25194](https://github.com/Jaeman-Lee/dotfiles/commit/fb25194). 기존 입력 운영 절차는 [Ubuntu 웹 원격 화면](ubuntu-web-desktop.md#ipad)을 따른다. 실행 로그·대화 원문·세션 식별자·인증 정보는 기록하지 않는다. -## 확인 결과 +## 이번 작업의 마감 + +2026-09-28 사용자 요청에 따라 이번 원격 테스트와 진단은 여기까지 진행하고 Git 기록을 남긴다. +운영 배포·서비스 재시작·PR 병합은 수행하지 않는다. 해결되지 않은 항목은 이슈에서 후속 보류로 유지한다. + +- iPad의 기존 **글쓰기 → 전송** 경로에서 긴 한글 문장이 정상 수신되는 것을 사용자와 확인했다. +- 사용자는 글쓰기 클릭 후 간헐적으로 PC 터미널로 입력되는 현상도 보고했다. + 포커스 보호·접기 수정과 Ctrl+Enter 전송은 구현 및 검증을 마쳤지만 **운영에는 미적용**이다. + 구현·검증 원본은 [운영 문서의 변경 기록](ubuntu-web-desktop.md#글쓰기-포커스-보호와-ctrlenter--2026-09-28)을 따른다. +- 직접 입력의 편의성을 우선하고 글쓰기는 보조 수단으로 남기는 방향을 사용자가 선호했다. + 직접 입력 개선은 구현하지 않았다. 실제 브라우저와 외장/화면 키보드 구분도 확인이 남았다. +- 모델 응답 연결 종료 및 재시도 기록과 두 작업의 후속 완료를 확인했다. + 다만 이후 사용자가 제공한 캡처는 별도의 **app-server 연결 상실 및 세션 자동 복원 실패**였다. + 아래 두 작업의 완료를 캡처 속 세션 복구의 증거로 사용할 수 없다. + +## 초기 확인 결과 14:47–14:51 KST에 읽기 전용으로 확인했다. 해당 시점의 관측이며 간헐적인 장애를 배제하지 않는다. @@ -31,16 +48,64 @@ vault: 미설정 기존 운영 문서에는 Guacamole Text input 경로에서 한글 누락과 PC 입력기의 영문 재해석을 실제 호스트에서 재현한 기록이 있다. iPad 사용자는 하단 **글쓰기**의 흰 입력칸에서 문장을 완성하고 **전송 ↵**을 누르는 기존 기능을 우선 사용한다. -실제 사용 중인 브라우저·원격 앱, 외장/화면 키보드 여부와 이 입력칸에서도 문제가 발생하는지는 확인이 남았다. +후속 확인에서 이 입력칸을 통한 한글 문장 전송은 성공했다. 간헐적인 포커스 문제는 별도로 보고되었다. -## 검증과 남은 확인 +## 초기 진단의 검증 범위 `node --test remote-desktop/tests/mobile-input.test.cjs`: 기존 회귀 테스트 9개 통과. 이는 전송 순서·중복 방지·초안 보존 검증이며 iPad 실기기 한글 조합 성공을 증명하지 않는다. -소스 코드, PC 입력기 설정, 원격 서비스와 Codex 실행 상태는 변경하지 않았다. +초기 진단에서는 소스 코드, PC 입력기 설정, 원격 서비스와 Codex 실행 상태를 변경하지 않았다. 기존 세션이나 daemon을 재시작하지 않았다. 미전송 입력과 실행 중 작업을 보존했다. -실제 오류 문구와 영향받는 세션의 연결 상태를 확인한 뒤 복구 범위를 결정한다. -한글 입력 문제와 세션 연결 문제 모두 해결 완료로 처리하지 않는다. +후속 소스 수정과 17개 회귀 테스트 결과는 운영 문서에 기록했으며, 운영 서비스는 계속 유지했다. + +## Codex 재접속 후속 확인 + +2026-09-28 22:12–22:17 KST에 보존된 로그와 작업 완료 이벤트를 읽기 전용으로 확인했다. +다음 시각은 모두 KST이며, 확인 가능한 사례만 기술한다. + +| 시각 | 관측 | 확인 범위 | +| --- | --- | --- | +| 09-27 19:57:36, 20:58:13 | 응답 WebSocket의 60분 연결 제한에 도달했다는 오류 후 재시도 | 이 두 건의 재접속 사유가 오류에 명시되어 있다. | +| 09-28 18:30:18 | 응답 완료 전 `Connection closed normally`로 연결 종료, 재시도 1/5 | 같은 대화의 작업 완료 이벤트가 18:30:52에 기록되어 약 34초 뒤 완료를 확인했다. | +| 09-28 19:38:04 | 같은 연결 종료와 재시도 1/5 | 작업 완료 이벤트가 19:38:18에 기록되어 약 14초 뒤 완료를 확인했다. | +| 09-28 22:11:48, 22:11:57 | 로컬 제어 소켓의 WebSocket handshake 미완료 | 앞선 모델 서버 응답 연결 종료와 동일한 원인이라고 단정하지 않는다. | + +`Connection closed normally`는 연결 종료 방식의 설명이며, 답변 생성이 성공적으로 끝났다는 의미가 아니다. +오늘 두 건에서 연결을 닫은 근본 원인이 서버인지 네트워크인지 로그만으로 확정할 수 없다. +별도로 여러 터미널에서 `account/rateLimits/read` 시간 초과가 반복되었다. +이는 조회 응답이 지연된 기록이며, 사용량 한도를 소진했다는 증거는 아니다. + +원격 웹 컨테이너는 확인 당시 약 23시간 연속 실행 중이었고 OOM 종료 흔적은 없었다. +GNOME 원격 서비스도 유지되어 있었다. 이번 작업에서 원격 웹·Codex daemon을 재시작하거나 +수정본을 운영에 적용한 사실은 없다. 이 관측만으로 과거의 모든 네트워크 끊김을 배제하지 않는다. + +### 사용자 캡처 확인에 따른 정정 + +사용자가 안내한 로컬 Screenshots 폴더의 2026-09-28 22:23:46 캡처를 직접 확인했다. +캡처 원본과 화면의 개인 작업 내용은 Git에 포함하지 않고 진단에 필요한 오류 문구만 기록한다. + +- `Connection lost. Attempting to reconnect…` +- `Automatic reconnect could not restore this session.` +- `app-server session could not be restored` +- 화면에는 Ctrl+C 후 같은 명령으로 재연결하라는 안내도 표시되어 있다. + +이 오류는 Codex 터미널과 app-server 사이의 연결을 잃은 뒤 기존 세션을 자동 복원하지 +못한 상태를 보여 준다. 앞서 모델 응답 스트림의 재시도만으로 사용자 증상을 설명한 것은 +불충분했다. 단순 모델 응답 재시도와 터미널 세션 자동 복원 실패를 구분하여 후속 조사해야 한다. +캡처만으로 대화 기록 삭제나 작업 유실 여부를 판단할 수 없다. + +현재 daemon 프로세스 시작 시각은 09-28 14:25:18 KST이며 CLI 0.157.0 / daemon 0.158.0 +조합을 다시 확인했다. 22:20:34와 22:24:05에는 app-server WebSocket의 closing handshake +없는 connection reset도 기록되었다. 캡처 시각이 실제 최초 장애 시각과 같다고 볼 수 없으므로 +이 시각·버전 차이·주변 연결 오류를 곧바로 해당 실패의 원인으로 단정하지 않는다. +재시작·업데이트·세션 재연결은 수행하지 않았으며 이번 추가 확인은 진단 기록 정정으로 한정한다. + +## 후속 작업의 경계 + +실제 할 일과 상태는 [이슈 #20](https://github.com/Jaeman-Lee/dotfiles/issues/20) 및 +[입력 개선 이슈 #22](https://github.com/Jaeman-Lee/dotfiles/issues/22)에서 관리한다. +다시 진행할 때는 운영 적용 여부를 먼저 정하고, 적용한다면 기기 새로고침 후 iPad 실기기 포커스·단축키를 확인한다. +직접 한글 입력 개선과 반복 재접속의 근본 원인 조사는 별도 후속 범위다. 참고: [OpenAI 공식 설정 문서](https://developers.openai.com/codex/config-reference/). diff --git a/docs/ubuntu-web-desktop.md b/docs/ubuntu-web-desktop.md index 9d362a0..5ee0730 100644 --- a/docs/ubuntu-web-desktop.md +++ b/docs/ubuntu-web-desktop.md @@ -106,6 +106,44 @@ PC 클립보드는 작성한 문장으로 바뀐다. 문장은 연결 화면을 글쓰기 방식은 기기에서 조합을 끝낸 문장을 클립보드로 보내 이 문제를 피한다. PC의 전역 한/영 단축키는 임의로 변경하지 않는다. +### 글쓰기 포커스 보호와 Ctrl+Enter — 2026-09-28 + +요구사항·진행 상태: [이슈 #22](https://github.com/Jaeman-Lee/dotfiles/issues/22). +변경 검토: [draft PR #23](https://github.com/Jaeman-Lee/dotfiles/pull/23), +구현·검증: [커밋 756a53e](https://github.com/Jaeman-Lee/dotfiles/commit/756a53e). +**소스 수정·검증 완료, 운영 적용 대기.** 현재 원격 접속을 끊는 웹 컨테이너 재시작은 아직 수행하지 않았다. +사용자 요청으로 이번 테스트는 여기서 마감하고 운영 적용과 실기기 후속 확인은 보류한다. +최종 수신 확인·재접속 진단과 작업 범위는 [원격 테스트 마감 기록](ipad-input-session-diagnostics-20260928.md)에 남긴다. +아래 동작은 변경본을 적용하고 기기 페이지를 새로고침한 뒤 사용할 수 있다. + +- **글쓰기**를 누르면 표시를 동기적으로 갱신한 뒤 흰 입력칸에 초점을 둔다. +- 글쓰기 모드에서는 입력칸이 잠깐 초점을 잃어도 물리 키 이벤트를 PC로 전달하지 않는다. + Guacamole의 숨은 입력칸이 초점을 가져가려 하면 흰 입력칸으로 되돌린다. + PC 화면 클릭과 대상 선택은 가능하다. 직접 PC에 타이핑하려면 **접기**를 누른다. +- 흰 입력칸의 **Ctrl+Enter**는 **전송 ↵**과 같다. **Enter**는 줄바꿈이다. + 조합 중인 한글, 빈 문장, 키 자동 반복으로는 전송하지 않는다. 전송 중 연타는 기존 전송 잠금을 따른다. +- 버튼 전송과 붙여넣기 후에도 입력칸에 초점을 두어 다음 문장을 이어서 작성할 수 있다. + +원인과 수정: 이전 코드는 textarea에 실제 초점이 있을 때만 키 전달을 막았다. +입력칸 표시에는 `ng-show`가 사용되어 표시 전환과 동기적인 `focus()`를 분리하지 않았다. +변경본은 애니메이션 없는 `ng-style` 표시와 모드 전체의 입력 차단을 사용한다. +숨은 InputSink의 focus 핸들러는 비동기 select를 예약하므로, focus 캡처 단계에서 차단해 +반복 재포커스가 발생하지 않도록 했다. 또한 `ng-if` 내부의 접기 버튼이 자식 scope에 +`writing`을 따로 생성하여 실제 글쓰기 모드를 끄지 못하는 문제를 브라우저에서 재현했고, +directive scope의 함수로 상태를 바꾸도록 수정했다. iPad에서 보고된 간헐 현상의 정확한 +이벤트 순서는 아직 실기기에서 계측하지 않았으므로 모든 원인을 확정한 것으로 보지 않는다. + +검증: Node 회귀 테스트 17개 통과. 실제 Guacamole 1.6.0 Keyboard/InputSink와 Angular를 +사용한 별도 브라우저 fixture에서 Ctrl+Enter의 클립보드→붙여넣기→Enter 순서, +전송 후 비우기, Enter 줄바꿈, 조합 중 전송 차단, 초점 이탈 시 원격 키 차단, +숨은 입력칸의 포커스 회수와 접기 후 직접 키 입력 재개를 확인했다. +이 fixture는 실제 PC 터미널에 연결하지 않는다. 1024px 브라우저 표시도 확인했으나 +iPad Safari 실기기 및 변경본의 RDP 종단간 전송은 별도 확인이 남아 있다. + +사용자가 이번 수정 전 기존 **글쓰기→전송** 경로에서 긴 한글 문장의 정상 수신을 확인했다. +이는 [앞선 진단](ipad-input-session-diagnostics-20260928.md)의 기본 한글 전송 확인을 갱신한다. +기존 Codex 세 세션의 연결 재시도는 별도 문제로, 이 입력 UI 변경의 해결 범위에 포함하지 않는다. + 갤럭시와 iPad 모두 주소를 즐겨찾기에 저장하면 다음 접속이 간단하다. 두 기기에서 동시에 연결하는 기능은 별도 검증하지 않았으므로 우선 한 기기씩 사용한다. diff --git a/remote-desktop/mobile-input/mobile-input.js b/remote-desktop/mobile-input/mobile-input.js index b6fbebb..8de292e 100644 --- a/remote-desktop/mobile-input/mobile-input.js +++ b/remote-desktop/mobile-input/mobile-input.js @@ -13,9 +13,26 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout', if (scope.writing) scope.menu.inputMethod = 'none'; var draft = element[0].querySelector('#ubuntu-writing-text'); + var composing = false; + function compositionStarted() { composing = true; } + function compositionEnded() { composing = false; } + draft.addEventListener('compositionstart', compositionStarted); + draft.addEventListener('compositionend', compositionEnded); + // The native keyboard/IME edits this local draft. Never forward its // physical key events to the remote client as well. - function localKey(event) { event.stopPropagation(); } + function localKey(event) { + event.stopPropagation(); + if (event.type === 'keydown' && event.key === 'Enter' && event.ctrlKey && + !event.altKey && !event.metaKey && !event.shiftKey && + !composing && !event.isComposing && event.keyCode !== 229) { + event.preventDefault(); + // An empty/repeated shortcut must never execute a bare PC Enter. + if (!event.repeat && scope.draft) { + scope.$apply(function () { scope.pasteDraft(true); }); + } + } + } ['keydown', 'keyup', 'keypress'].forEach(function (name) { draft.addEventListener(name, localKey); }); @@ -24,10 +41,28 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout', // local form editing out of the remote desktop. ['guacBeforeKeydown', 'guacBeforeKeyup'].forEach(function (name) { scope.$on(name, function (event) { - if ($window.document.activeElement === draft) event.preventDefault(); + // Focus can briefly land on Guacamole's hidden input sink, + // especially after a touch or browser focus transition. + // Fail closed for the entire writing mode, not just textarea focus. + if (scope.writing || $window.document.activeElement === draft) + event.preventDefault(); }); }); + function protectFocus(event) { + if (!scope.writing || scope.menu.shown || event.target === draft) return; + var target = event.target; + if (target.tagName !== 'TEXTAREA') return; + var rect = target.getBoundingClientRect(); + if (target.id === 'ubuntu-remote-text' || (!rect.width && !rect.height)) { + // Stop before InputSink's target focus listener schedules a + // select()/refocus timer, or the two fields can fight forever. + event.stopImmediatePropagation(); + draft.focus(); + } + } + $window.document.addEventListener('focus', protectFocus, true); + var writingButton = element[0].querySelector('.ubuntu-open-writing'); function openWriting() { scope.$apply(function () { @@ -38,6 +73,12 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout', draft.focus(); } writingButton.addEventListener('click', openWriting); + scope.closeWriting = function () { + // The button lives inside ng-if's child scope. Change the + // directive scope here instead of shadowing `writing` there. + scope.writing = false; + scope.menu.inputMethod = 'none'; + }; var pendingPaste, pendingEnter; var lastPaste = null; @@ -55,6 +96,9 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout', return; } scope.sending = true; + // Keep typing locally after clicking Send/Paste, including on iPad + // where opening the keyboard needs the original user gesture. + if (scope.writing) draft.focus(); var client = managed.client; var terminal = scope.terminal; var text = scope.draft; @@ -153,6 +197,9 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout', scope.$on('$destroy', function () { button.removeEventListener('click', openKeyboard); writingButton.removeEventListener('click', openWriting); + $window.document.removeEventListener('focus', protectFocus, true); + draft.removeEventListener('compositionstart', compositionStarted); + draft.removeEventListener('compositionend', compositionEnded); ['keydown', 'keyup', 'keypress'].forEach(function (name) { draft.removeEventListener(name, localKey); }); diff --git a/remote-desktop/mobile-input/toolbar.html b/remote-desktop/mobile-input/toolbar.html index fbde67a..a1823f9 100644 --- a/remote-desktop/mobile-input/toolbar.html +++ b/remote-desktop/mobile-input/toolbar.html @@ -3,26 +3,26 @@
- +
-
+
- - + +
-
{{notice || '한/영은 기기 키보드에서 전환 · PC 클립보드를 사용합니다.'}}
+
{{notice || '글쓰기 중에는 PC 직접 타이핑이 잠깁니다. 직접 입력하려면 접기를 누르세요.'}}
diff --git a/remote-desktop/tests/mobile-input.test.cjs b/remote-desktop/tests/mobile-input.test.cjs index fe7d55f..e4d7984 100644 --- a/remote-desktop/tests/mobile-input.test.cjs +++ b/remote-desktop/tests/mobile-input.test.cjs @@ -6,7 +6,34 @@ const path = require('node:path'); function harness() { const calls = [], timers = [], listeners = {}; - const element = {addEventListener() {}, removeEventListener() {}, focus() {}}; + const documentListeners = {}; + const document = { + activeElement: null, + addEventListener(name, fn) { documentListeners[name] = fn; }, + removeEventListener(name) { delete documentListeners[name]; }, + getElementById() { return remote; } + }; + function node(id) { + const events = {}; + return { + id, tagName: 'TEXTAREA', events, + addEventListener(name, fn) { events[name] = fn; }, + removeEventListener(name) { delete events[name]; }, + focus() { document.activeElement = this; }, + getBoundingClientRect() { return {width: 100, height: 70}; }, + fire(type, options = {}) { + const event = {type, defaultPrevented: false, stopped: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.stopped = true; }, ...options}; + if (events[type]) events[type](event); + return event; + } + }; + } + const draft = node('ubuntu-writing-text'), writing = node('writing'), + keyboard = node('keyboard'), remote = node('ubuntu-remote-text'); + const nodes = {'#ubuntu-writing-text': draft, '.ubuntu-open-writing': writing, + '.ubuntu-open-keyboard': keyboard}; const scope = { menu: {}, $on(name, fn) { listeners[name] = fn; }, @@ -21,15 +48,15 @@ function harness() { let directive; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../mobile-input/mobile-input.js'), 'utf8'), { angular: {module: () => ({directive: (name, deps) => { - directive = deps.at(-1)({navigator: {maxTouchPoints: 1}, document: {}}, timeout); + directive = deps.at(-1)({navigator: {maxTouchPoints: 1}, document}, timeout); }})}, Guacamole: {StringWriter: function () { this.sendText = text => calls.push(['clipboard', text]); this.sendEnd = () => calls.push(['end']); }} }); - directive.link(scope, [{querySelector: () => element}]); - return {scope, calls, listeners, step() { + directive.link(scope, [{querySelector: selector => nodes[selector]}]); + return {scope, calls, listeners, draft, writing, keyboard, remote, document, documentListeners, step() { const timer = timers.shift(); if (timer && !timer.cancelled) timer.fn(); }}; @@ -103,3 +130,82 @@ test('leaving the view cancels pending Enter', () => { h.scope.sendEnter(); h.step(); h.listeners.$destroy(); h.step(); assert.equal(h.calls.some(c => c[2] === 0xff0d), false); }); + +test('opening writing restores the local draft focus', () => { + const h = harness(); h.scope.writing = false; h.scope.menu.inputMethod = 'text'; + h.remote.focus(); h.writing.fire('click'); + assert.equal(h.scope.writing, true); + assert.equal(h.scope.menu.inputMethod, 'none'); + assert.equal(h.document.activeElement, h.draft); +}); +test('writing blocks remote capture even after draft loses focus', () => { + const h = harness(); h.document.activeElement = h.remote; + for (const name of ['guacBeforeKeydown', 'guacBeforeKeyup']) { + let prevented = false; + h.listeners[name]({preventDefault() { prevented = true; }}); + assert.equal(prevented, true); + } + assert.deepEqual(h.calls, []); +}); +test('hidden sink and legacy textarea cannot steal writing focus', () => { + const h = harness(); + for (const target of [h.remote, {tagName: 'TEXTAREA', + getBoundingClientRect() { return {width: 0, height: 0}; }}]) { + h.document.activeElement = target; + let stopped = false; + h.documentListeners.focus({target, stopImmediatePropagation() { stopped = true; }}); + assert.equal(stopped, true); + assert.equal(h.document.activeElement, h.draft); + } + const select = {tagName: 'SELECT'}; + h.document.activeElement = select; h.documentListeners.focus({target: select}); + assert.equal(h.document.activeElement, select); +}); +test('collapsing or selecting legacy keyboard restores direct input', () => { + const h = harness(); + // ng-if calls the inherited function from a child scope. + const child = Object.create(h.scope); child.closeWriting(); + assert.equal(h.scope.writing, false); + h.document.activeElement = h.remote; + let prevented = false; + h.listeners.guacBeforeKeydown({preventDefault() { prevented = true; }}); + assert.equal(prevented, false); + h.scope.writing = true; h.keyboard.fire('click'); + assert.equal(h.scope.writing, false); + assert.equal(h.document.activeElement, h.remote); +}); +test('Ctrl+Enter sends once and retains ordinary Enter for local newlines', () => { + const h = harness(); h.scope.draft = '한글 shortcut'; + const newline = h.draft.fire('keydown', {key: 'Enter'}); + assert.equal(newline.defaultPrevented, false); + assert.equal(newline.stopped, true); + assert.deepEqual(h.calls, []); + const shortcut = h.draft.fire('keydown', {key: 'Enter', ctrlKey: true}); + assert.equal(shortcut.defaultPrevented, true); + h.draft.fire('keydown', {key: 'Enter', ctrlKey: true}); + h.step(); h.step(); + assert.equal(h.calls.filter(c => c[0] === 'clipboard').length, 1); + assert.deepEqual(h.calls.slice(-2), enter); + assert.equal(h.document.activeElement, h.draft); +}); +test('Ctrl+Enter never sends while IME is composing or on key repeat', () => { + const h = harness(); h.scope.draft = '조합 중'; + h.draft.fire('compositionstart'); + h.draft.fire('keydown', {key: 'Enter', ctrlKey: true}); + h.draft.fire('compositionend'); + for (const options of [{isComposing: true}, {keyCode: 229}, {repeat: true}]) + h.draft.fire('keydown', {key: 'Enter', ctrlKey: true, ...options}); + assert.deepEqual(h.calls, []); + h.draft.fire('keydown', {key: 'Enter', ctrlKey: true}); + assert.equal(h.calls[0][0], 'clipboard'); +}); +test('empty Ctrl+Enter does not execute the remote prompt', () => { + const h = harness(); + assert.equal(h.draft.fire('keydown', {key: 'Enter', ctrlKey: true}).defaultPrevented, true); + assert.deepEqual(h.calls, []); +}); +test('focus protection is removed when the view is destroyed', () => { + const h = harness(); h.listeners.$destroy(); + assert.equal(h.documentListeners.focus, undefined); + assert.deepEqual(Object.keys(h.draft.events), []); +});