diff --git a/.issueops/adr/2026-10-11-google-drive-auth-gateway-appproperties.md b/.issueops/adr/2026-10-11-google-drive-auth-gateway-appproperties.md new file mode 100644 index 0000000..fb2e2c1 --- /dev/null +++ b/.issueops/adr/2026-10-11-google-drive-auth-gateway-appproperties.md @@ -0,0 +1,27 @@ +--- +name: 2026-10-11-google-drive-auth-gateway-appproperties +description: Accepted decision record with rationale, alternatives, and consequences. +--- + +# Google Drive 연동은 auth-gateway가 토큰을 교환하고 appProperties로 무상태 동기화한다 + +- Date: 2026-10-11 +- Kind: `adr` +- Source: issueops-docs io-21fd3b1e1f06 (galpi#12) +- Summary: Galpi는 drive.file 범위의 데스크톱 OAuth client ID만 갖고, 코드 교환과 갱신은 auth-gateway의 POST /drive/native/token이 Galpi 세션 bearer로 대행한다. 회의 폴더와 결과 파일은 Drive appProperties(galpiRoot, galpiMeeting, galpiKind, galpiSha256)와 sha256Checksum으로 다시 찾고 충돌을 판정하므로 로컬에 Drive 파일 기록을 두지 않는다. +- Context: galpi#12. 데스크톱 OAuth 클라이언트는 보안 비밀번호 없이 교환하면 400 client_secret is missing을 받는다. 바이너리를 배포하지 않고 소스 빌드로 배포하는 저장소라 비밀번호를 소스에 둘 수 없다(사용자 결정). 같은 오디오를 다시 전사하면 출력 폴더가 `{stem} 2`로 바뀌어 출력 폴더 기반 기록은 끊긴다. +- Decision: 1) 클라이언트 보안 비밀번호는 auth-gateway 서버 설정에만 두고, 앱은 AppAccessGate::bearer()로 얻은 gateway access token을 bearer로 붙여 /drive/native/token에 교환·갱신을 맡긴다. bearer는 브라우저 동의가 끝난 뒤 얻는다. 2) 400 invalid_grant만 Drive 연결을 지우고(DRIVE_RECONNECT_REQUIRED), gateway 401은 호스트 세션도 닫는다(AppAccessGate::expire). 그 밖의 실패는 DRIVE_UNAVAILABLE로 끝내고 재시도하지 않는다. 3) 회의 키는 Drive에서 가져온 녹음이면 `drive:`, 아니면 정경화한 원본 경로의 FNV-1a 해시(`local:<16hex>`)다. domain은 sha2를 쓰지 않는다. 4) 충돌 판정은 순수 함수 decide(remote, local_sha256, overwrite): Drive sha256Checksum == galpiSha256이면 아무도 손대지 않은 파일이다. 5) 업로드는 전사 명령과 분리된 sync_meeting_to_drive IPC로 하고 한 파일의 실패는 그 파일 상태로만 보고한다. +- Consequences: auth-gateway#10의 /drive/native/token이 배포되지 않으면 Drive 연결이 DRIVE_UNAVAILABLE로 실패할 뿐 다른 기능은 영향이 없다. Drive에서 고른 파일 외의 사용자 파일에는 접근하지 않는다. sha256Checksum이 없는 파일은 충돌로 취급되므로, 실제 스모크에서 업로드 직후 checksum이 있음을 확인했다(.issueops/evidence/task-12-drive-smoke.md). 포트가 3개(DriveAuthPort, DriveFilesPort, DriveSessionStore) 늘어 18개, IPC 명령이 7개 늘어 35개다. +- Evidence: + - src-tauri/src/domain/drive.rs (decide, MeetingKey, stable_hash) + - src-tauri/src/application/drive.rs (DriveAccounts::consent, token_failure) + - src-tauri/src/application/gateway.rs (bearer, expire) + - src-tauri/src/adapters/outbound/drive/tokens.rs + - .issueops/evidence/task-12-drive-smoke.md +- Alternatives / rejected options: + - 클라이언트 보안 비밀번호를 소스 상수로 포함: 사용자가 gateway 대행으로 재결정해 기각. + - 빌드 때 비밀번호 주입, 주입 안 된 빌드는 Drive 비활성: 소스 빌드 사용자가 기능을 쓸 수 없어 기각. + - 로컬 사이드카 파일에 Drive 파일 ID·리비전 저장: 재전사 때 새 출력 폴더가 생겨 기록이 끊겨 기각. + - drive.readonly로 Galpi 폴더 전체 동기화: 제한 범위 심사가 필요해 기각. + - 앱 안 웹 Picker(iframe): CSP frame-src 'none'과 WebKit 서드파티 쿠키 차단으로 기각, 시스템 브라우저 데스크톱 Picker 채택. + - 전사 명령 안에서 업로드: 업로드 실패가 전사 결과를 막아 기각. diff --git a/.issueops/adr/overview.md b/.issueops/adr/overview.md index 790936a..14d6878 100644 --- a/.issueops/adr/overview.md +++ b/.issueops/adr/overview.md @@ -33,3 +33,4 @@ Record structural choices, rejected alternatives, and decisions that affect long - : - Consequences: - Evidence: +- [Google Drive 연동은 auth-gateway가 토큰을 교환하고 appProperties로 무상태 동기화한다](2026-10-11-google-drive-auth-gateway-appproperties.md) diff --git a/.issueops/architecture/overview.md b/.issueops/architecture/overview.md index 47ba580..5618dd4 100644 --- a/.issueops/architecture/overview.md +++ b/.issueops/architecture/overview.md @@ -18,7 +18,7 @@ rule, with DDD tactical patterns — applied consistently across three runtimes: ```text TypeScript (WebView) Rust (Tauri host) Python (WhisperX sidecar) ui/ outer adapters/inbound/tauri.rs __main__.py outer -application/ application/ (15 ports) engine/refine use cases +application/ application/ (18 ports) engine/refine use cases domain/ inner domain/ (inner) protocol.py (port: stdout) adapters/tauri-backend composition.rs (root) domain pure modules ``` @@ -32,11 +32,14 @@ adapters/tauri-backend composition.rs (root) domain pure modules - DDD tactical: value objects (`SpeakerHint`, `Participant`, `GlossaryEntry`, `AssistantSettings`), aggregate root `Artifacts::path_for(kind)`, domain services as pure functions, conceptual in-memory `JobRegistry`. -- Inbound adapters: `tauri.rs` (28 `#[tauri::command]`s incl. `save_compute_device`, + event bridge), +- Inbound adapters: `tauri.rs` (35 `#[tauri::command]`s incl. `save_compute_device` and + the 7 Drive commands, + event bridge), `tauri-backend.ts` (Zod boundary), worker `__main__.py`. - Outbound adapters: `process.rs` (worker supervision), `NativeRecorder` - (CPAL), settings/paths adapters, `ChatGptOAuthAdapter` and - `GatewayOAuthAdapter` over the shared `browser_sign_in/` loopback and PKCE, + (CPAL), settings/paths adapters, `ChatGptOAuthAdapter`, `GatewayOAuthAdapter`, + and `DriveOAuthAdapter` over the shared `browser_sign_in/` loopback and PKCE, + `DriveRestAdapter` (Drive v3; tokens exchanged by auth-gateway + `/drive/native/token`, see adr/2026-10-11-google-drive-auth-gateway-appproperties.md), worker `EventWriter` (stdout JSONL v1). - Platform branching lives only in outbound adapters and `composition.rs`: outbound code branches on `Os` (`platform.rs`, `Os::current()` is the single diff --git a/.issueops/cautions/2026-10-11-check.md b/.issueops/cautions/2026-10-11-check.md new file mode 100644 index 0000000..b58e165 --- /dev/null +++ b/.issueops/cautions/2026-10-11-check.md @@ -0,0 +1,16 @@ +--- +name: 2026-10-11-check +description: Caution record for a solved false case or recurring risk. +--- + +# 게이트 CHECK 인자에 비밀처럼 보이는 문자열을 넣으면 명령 정책이 실행을 거부한다 + +- Date: 2026-10-11 +- Kind: `caution` +- Source: issueops-docs io-21fd3b1e1f06 (galpi#12) +- Summary: '소스에 Google 클라이언트 비밀번호가 없다'를 확인하는 게이트가 CHECK 인자에 비밀번호 접두어와 SECRET이 들어간 표식 문자열을 담자 issueops 명령 정책이 secret_like_argument로 거부해 게이트가 unchecked로 남았다. +- Context: galpi#12 G11. 같은 명령을 셸에서 실행하면 정상 동작해 원인이 보이지 않는다. 출력에 자격 증명처럼 보이는 값이 있어도 EVIDENCE가 가려져 EXPECT가 맞지 않는다. +- Resolution: 검색 패턴은 실행 중에 조립하고(chr(71)+'OCSPX'), 출력 표식은 SOURCE_CLEAN/SOURCE_LEAK처럼 중립 단어를 쓴다. 게이트가 unchecked로 남으면 `issueops gates check --json` 결과의 check_error에서 policy_denied 여부부터 본다. 커밋 전에는 새 파일이 미추적이라 `git grep`에 `--untracked`를 붙여야 하고, 접두어만 찾으면 계획·게이트 문서의 설명 문구에도 걸리므로 실제 값 모양(접두어 뒤 `-`와 20자 이상)을 `-E`로 찾는다. +- Evidence: + - .issueops/issues/12/gates.md G11 + - issueops gates check --json: check_error 'check denied by policy: secret_like_argument' diff --git a/.issueops/cautions/2026-10-11-drive-path-windows.md b/.issueops/cautions/2026-10-11-drive-path-windows.md new file mode 100644 index 0000000..227793d --- /dev/null +++ b/.issueops/cautions/2026-10-11-drive-path-windows.md @@ -0,0 +1,13 @@ +--- +name: 2026-10-11-drive-path-windows +description: Caution record for a solved false case or recurring risk. +--- + +# Drive 파일 이름을 Path로 해석하면 Windows에서 결과가 달라진다 + +- Date: 2026-10-11 +- Kind: `caution` +- Source: issueops io-21fd3b1e1f06 generation 3 (CI run 38081588654) +- Summary: Drive 파일 이름 같은 외부 문자열을 std::path::Path로 나누면 OS마다 결과가 다르다. Windows는 a:b?.WAV의 a:를 드라이브 접두어로 보고 버려 b.WAV를 내고, macOS는 \를 일반 문자로 보아 C:\x\y.m4a를 C--x-y.m4a로 만든다. +- Context: issue #12 PR #13 CI의 rust (windows-latest)에서 drive::rest::tests::downloaded_names_are_sanitized_and_keep_the_extension이 실패했다. macOS 로컬과 macOS CI는 통과해 머지 미리보기의 checks_failing에서야 드러났다. +- Resolution: local_name(drive/rest.rs)은 Path 없이 문자열을 / 와 \ 로 나눠 빈 조각과 . 을 건너뛴 마지막 조각을 쓰고(..이면 meeting), 확장자는 Path::file_stem과 같은 규칙으로 직접 나눈다. 이름 정리는 paths::sanitized_stem을 재사용한다. 실제 로컬 파일 경로가 아닌 문자열(원격 이름, 사용자 입력)에는 Path를 쓰지 않고, 테스트에 C:\x\y.m4a, ..\..\boot.ini, C:, c:.wav 같은 Windows 구문을 넣어 macOS에서도 RED가 나게 한다. diff --git a/.issueops/cautions/2026-10-11-picker-google-500.md b/.issueops/cautions/2026-10-11-picker-google-500.md new file mode 100644 index 0000000..16be2f4 --- /dev/null +++ b/.issueops/cautions/2026-10-11-picker-google-500.md @@ -0,0 +1,16 @@ +--- +name: 2026-10-11-picker-google-500 +description: Caution record for a solved false case or recurring risk. +--- + +# 데스크톱 Picker 동의 화면의 Google 500은 일시 오류이며 새로고침으로 이어진다 + +- Date: 2026-10-11 +- Kind: `caution` +- Source: issueops-docs io-21fd3b1e1f06 (galpi#12) +- Summary: Drive 연결 동의 직후 Picker(trigger_onepick) 동의 URL이 accounts.google.com/signin/oauth/v3/consent에서 500을 한 번 돌려줬고, 같은 탭을 새로고침하자 drive.google.com/picker/oauth/consent로 정상 진행됐다. +- Context: galpi#12 실제 스모크(2026-10-10 17:40Z). 같은 파라미터 조합(기본, login_hint, mimetypes, 둘 다)을 다른 redirect로 다시 열었을 때는 500이 재현되지 않았다. 루프백 대기는 5분이라 새로고침해도 같은 시도로 콜백이 도착한다. +- Resolution: URL 파라미터를 바꾸거나 자동 재시도를 넣지 않는다. 스모크·QA에서 500을 보면 같은 탭을 새로고침하고, 사용자에게는 대기 문구('창이 보이지 않으면 취소 후 다시 시도')가 같은 해결을 안내한다. 재현되면 파라미터를 하나씩 빼서 별도 redirect로 원인을 좁힌다. +- Evidence: + - .issueops/evidence/task-12-drive-smoke.md + - src-tauri/src/adapters/outbound/drive/authorize.rs consent_url diff --git a/.issueops/cautions/2026-10-11-setbusy-kind-renderjob.md b/.issueops/cautions/2026-10-11-setbusy-kind-renderjob.md new file mode 100644 index 0000000..f91ad19 --- /dev/null +++ b/.issueops/cautions/2026-10-11-setbusy-kind-renderjob.md @@ -0,0 +1,17 @@ +--- +name: 2026-10-11-setbusy-kind-renderjob +description: Caution record for a solved false case or recurring risk. +--- + +# 작업 시작 시 setBusy(kind)를 renderJob보다 먼저 불러야 진행 카드 제목이 새 작업을 따른다 + +- Date: 2026-10-11 +- Kind: `caution` +- Source: issueops-docs io-21fd3b1e1f06 (galpi#12) +- Summary: AppController.begin()이 renderJob()을 setBusy(kind)보다 먼저 부르면 AppView.jobKind가 이전 작업 종류로 남아, 같은 #job-panel을 쓰는 새 작업(Drive 가져오기)의 제목과 가시성이 이전 작업 기준으로 그려진다. +- Context: galpi#12 Drive 가져오기는 전사와 같은 #job-panel 진행 카드를 쓴다. 템플릿의 기본 제목이 '회의를 전사하고 있습니다'라서 전사 작업에서는 문제가 드러나지 않았고, 계획 6차 리뷰는 '다음 전사 시작 직후 제목이 잠깐 틀림'을 비차단으로 남겼다. +- Resolution: begin()에서 setBusy(kind)로 jobKind를 먼저 정한 뒤 renderJob(this.job)을 부른다. setBusy(null)에서 jobKind를 비우지는 않는다(취소 제목이 깨진다). src/ui/drive.dom.test.ts의 'a pending import shows a running card…' 테스트가 #job-title을 고정한다. 같은 카드를 쓰는 작업 종류를 추가할 때는 app-view.ts의 usesJobPanel()과 renderJobTitle() 분기를 함께 고친다. +- Evidence: + - src/ui/controller.ts begin() + - src/ui/app-view.ts usesJobPanel, renderJobTitle, driveImportTitles + - src/ui/drive.dom.test.ts diff --git a/.issueops/cautions/overview.md b/.issueops/cautions/overview.md index 7b071da..2271f1f 100644 --- a/.issueops/cautions/overview.md +++ b/.issueops/cautions/overview.md @@ -9,3 +9,7 @@ Canonical index: [CAUTIONS.md](../CAUTIONS.md) - Generated docs are drafts; directly verify weak evidence. - Do not commit secrets, credentials, local state, or generated artifacts. +- [작업 시작 시 setBusy(kind)를 renderJob보다 먼저 불러야 진행 카드 제목이 새 작업을 따른다](2026-10-11-setbusy-kind-renderjob.md) +- [게이트 CHECK 인자에 비밀처럼 보이는 문자열을 넣으면 명령 정책이 실행을 거부한다](2026-10-11-check.md) +- [데스크톱 Picker 동의 화면의 Google 500은 일시 오류이며 새로고침으로 이어진다](2026-10-11-picker-google-500.md) +- [Drive 파일 이름을 Path로 해석하면 Windows에서 결과가 달라진다](2026-10-11-drive-path-windows.md) diff --git a/.issueops/evidence/task-12-drive-smoke.md b/.issueops/evidence/task-12-drive-smoke.md new file mode 100644 index 0000000..59a5999 --- /dev/null +++ b/.issueops/evidence/task-12-drive-smoke.md @@ -0,0 +1,29 @@ +# galpi#12 Drive 스모크 (2026-10-10, 실제 Google·auth-gateway·Drive) + +- 실행 방식: 운영 어댑터(`GatewayOAuthAdapter`, `DriveOAuthAdapter`, `DriveRestAdapter`)와 운영 use case(`AppAccessGate`, `DriveAccounts`)를 그대로 쓰는 임시 하네스(`cargo test --lib drive::smoke -- --ignored --nocapture`, 실행 후 삭제). 시스템 기본 브라우저(Aside)에서 실제 Google 동의·Picker를 조작했다. +- 앱 화면(`bun run dev`)으로 실행하지 않은 이유: 이 컴퓨터에는 Galpi 앱 데이터(엔진, 모델 약 3GB, gateway 세션)와 사용자 Hugging Face 토큰이 없어 전사 단계를 돌릴 수 없다. 화면 배선은 `src/ui/drive.dom.test.ts`(14개)와 Vite + 가짜 IPC 화면 확인으로 검증했다. +- 전제: auth-gateway `POST /drive/native/token` 배포 확인(bearer 없이 401 `missing bearer token`, 다른 경로는 404). auth-gateway#10 이슈·PR #11은 아직 열림 상태다. +- 저장소: 하네스 메모리 저장소. 사용자 설정 파일은 건드리지 않았다. 스모크가 만든 Drive 파일·폴더는 마지막에 휴지통으로 옮겼다. + +| 시각(UTC) | 단계 | 결과 | +|---|---|---| +| 17:39:12 → 17:39:56 | gateway Google 로그인 | `SignedIn { email: m16khb@gmail.com, offline: false }` | +| 17:39:56 → 17:40:45 | Drive 연결(`drive.file` 동의, "확인되지 않은 앱" 경고 없음) → gateway 대행 교환 → about → 폴더 | `Connected`, `Galpi` 폴더 생성 | +| 17:40:46 | 폴더 ID 없이 다시 찾기 | 같은 `Galpi` 폴더(`galpiRoot` appProperties 조회) | +| 17:40:48 → 17:44:32 | Picker 가져오기(`trigger_onepick`, `login_hint`, `mimetypes`) | 고른 ID가 업로드한 파일과 일치, 32,044바이트 다운로드, 로컬 수정 시각 17:40:47Z = Drive `modifiedTime` | +| 17:44:38 | 동기화 1(처음) | `Srt=Uploaded, SpeakerText=Uploaded` | +| 17:44:39 | 업로드 직후 Drive `sha256Checksum` | 있음, `galpiSha256`과 일치 | +| 17:44:40 | 동기화 2(같은 내용) | `Srt=Unchanged, SpeakerText=Unchanged` | +| 17:44:44 | 동기화 3(새 로컬 출력 폴더 `smoke meeting 2`로 재전사, 텍스트 변경) | `Srt=Unchanged, SpeakerText=Updated`, 같은 Drive 회의 폴더, 같은 파일 ID | +| 17:44:47 | 동기화 4(Drive에서 직접 수정 + 로컬 수정) | `SpeakerText=Conflict`(덮어쓰지 않음) | +| 17:44:50 | 동기화 5(덮어쓰기) | `SpeakerText=Updated` | +| 17:44:53 | 동기화 6(증강 뒤 같은 회의) | `Minutes=Uploaded`, 폴더 링크 있음 | +| 17:44:54 → 17:44:56 | 정리 | 회의 폴더·스모크 녹음·`Galpi` 폴더 휴지통 이동(200) | +| 17:44:57 | 연결 해제 | `Disconnected`, 저장소 비움, 이전 refresh token으로 갱신 시 `Rejected`(Google 폐기 확인) | +| 17:44:58 | gateway 로그아웃 | 완료 | + +## 관찰과 남은 확인 + +- Picker 동의 첫 화면에서 Google이 한 번 500을 돌려줬고 새로고침으로 진행됐다. 같은 파라미터 조합(기본, `login_hint`, `mimetypes`, 둘 다)을 별도 redirect로 다시 열었을 때는 500이 재현되지 않아 Google 쪽 일시 오류로 판단했다. 앱은 이 경우 사용자가 브라우저에서 새로고침하거나 취소 후 다시 시도하면 된다(대기 5분). +- 50MB 이상 녹음 다운로드 중 메모리 측정(계획의 성능 영향 절)은 하지 않았다. 스모크 파일은 32KB였다. 다운로드는 `Response::chunk()`를 파일에 바로 쓰는 구조이며 `downloads_land_in_a_fresh_folder_with_the_drive_modification_time` 어댑터 테스트가 `.part` 경유 저장을 고정한다. +- 다른 계정으로 고른 파일 거부(`DRIVE_ACCOUNT_MISMATCH`)와 실제 전사 뒤 자동 업로드는 실계정·엔진이 없어 application 테스트로만 확인했다. diff --git a/.issueops/issues/12/gates.md b/.issueops/issues/12/gates.md new file mode 100644 index 0000000..9d4dca6 --- /dev/null +++ b/.issueops/issues/12/gates.md @@ -0,0 +1,54 @@ +# Gates: 12 + +- [x] G1: Drive 도메인 규칙(충돌 판정 다섯 경우, 파일 ID 거부, 회의 키 한도, 토큰 Debug 가림)이 고정된다 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib domain::drive + EXPECT: /test result: ok\. [1-9][0-9]* passed; 0 failed/ + EVIDENCE: Finished `test` profile [unoptimized + debuginfo] target(s) in 7.06s | Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) +- [x] G2: 로그인 게이트 bearer()가 만료 전 재사용, 만료 구간 갱신과 회전 저장, 거부 시 세션 삭제, 미도달 시 세션 유지, 토큰 없음 시 AUTH_REQUIRED로 동작한다 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib application::tests::gateway + EXPECT: /test result: ok\. [1-9][0-9]* passed; 0 failed/ + EVIDENCE: Finished `test` profile [unoptimized + debuginfo] target(s) in 0.18s | Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) +- [x] G3: Drive 연결 정보와 토큰이 재시작 뒤에도 남고 OS 비밀 저장소 백엔드에서는 설정 파일에 토큰이 없다 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib settings::drive + EXPECT: /test result: ok\. [1-9][0-9]* passed; 0 failed/ + EVIDENCE: Finished `test` profile [unoptimized + debuginfo] target(s) in 0.14s | Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) +- [x] G4: Drive 인증·REST 어댑터가 가짜 서버 기준 gateway 대행 교환, 오류 매핑, multipart 업로드, 스트리밍 다운로드 계약을 지킨다 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib adapters::outbound::drive + EXPECT: /test result: ok\. [1-9][0-9]* passed; 0 failed/ + EVIDENCE: Finished `test` profile [unoptimized + debuginfo] target(s) in 0.16s | Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) +- [x] G5: Drive use case(로그인 게이트, 연결·해제, 가져오기 계정 확인과 취소, 동기화 생성·갱신·충돌·실패 격리)가 동작한다 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib application::tests::drive + EXPECT: /test result: ok\. [1-9][0-9]* passed; 0 failed/ + EVIDENCE: Finished `test` profile [unoptimized + debuginfo] target(s) in 0.18s | Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) +- [x] G6: Rust 전체 테스트가 실패 없이 통과한다 + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets + EXPECT: /test result: ok\. [1-9][0-9]* passed; 0 failed/ + EVIDENCE: Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) | Running unittests src/main.rs (src-tauri/target/debug/deps/galpi-3a6bea8a51165c32) +- [x] G7: Rust 린트가 경고 없이 통과한다 + CHECK: python3 -c "import subprocess,sys;r=subprocess.run([\"cargo\",\"clippy\",\"--manifest-path\",\"src-tauri/Cargo.toml\",\"--all-targets\",\"--\",\"-D\",\"warnings\"],capture_output=True);print(\"CLIPPY_OK\" if r.returncode==0 else \"CLIPPY_FAIL\");sys.exit(r.returncode)" + EXPECT: CLIPPY_OK + EVIDENCE: CLIPPY_OK +- [x] G8: Rust 형식이 맞다 + CHECK: python3 -c "import subprocess,sys;r=subprocess.run([\"cargo\",\"fmt\",\"--manifest-path\",\"src-tauri/Cargo.toml\",\"--check\"],capture_output=True);print(\"FMT_OK\" if r.returncode==0 else \"FMT_FAIL\");sys.exit(r.returncode)" + EXPECT: FMT_OK + EVIDENCE: FMT_OK +- [x] G9: 프런트 아키텍처 펜스, Biome, tsc가 통과한다 + CHECK: python3 -c "import subprocess,sys;r=subprocess.run([\"bun\",\"run\",\"check\"],capture_output=True);print(\"CHECK_OK\" if r.returncode==0 else \"CHECK_FAIL\");sys.exit(r.returncode)" + EXPECT: CHECK_OK + EVIDENCE: CHECK_OK +- [x] G10: 프런트 테스트가 실패 없이 통과한다 + CHECK: bun test + EXPECT: /^\s*0 fail$/m + EVIDENCE: 665 expect() calls | Ran 233 tests across 29 files. [2.11s] +- [x] G11: 소스에 클라이언트 보안 비밀번호가 없다 + CHECK: python3 -c "import subprocess,sys;p=chr(71)+'OCSPX-[A-Za-z0-9_-]{20,}';r=subprocess.run(['git','grep','--untracked','-nE',p],capture_output=True);print('SOURCE_CLEAN' if r.returncode==1 else 'SOURCE_LEAK');sys.exit(0 if r.returncode==1 else 1)" + EXPECT: SOURCE_CLEAN + EVIDENCE: SOURCE_CLEAN +- [x] G12: 실제 Drive 스모크(연결, 가져오기, 업로드, 재처리, 충돌, 해제) 결과가 증거 파일에 기록된다 + CHECK: python3 -c "import pathlib;print(pathlib.Path(\".issueops/evidence/task-12-drive-smoke.md\").read_text().splitlines()[0])" + EXPECT: /Drive 스모크/ + EVIDENCE: # galpi#12 Drive 스모크 (2026-10-10, 실제 Google·auth-gateway·Drive) +- [x] G13: 내려받은 Drive 파일 이름이 OS 경로 해석 없이 정리되어 Windows와 macOS에서 같은 결과를 낸다(`/`·`\` 구분자, 드라이브 접두어, 예약 문자) + EVIDENCE: Finished `test` profile [unoptimized + debuginfo] target(s) in 7.07s | Running unittests src/lib.rs (src-tauri/target/debug/deps/galpi_lib-8b8b8ffe811d62b8) + CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib downloaded_names_are_sanitized_and_keep_the_extension + EXPECT: /test result: ok\. 1 passed; 0 failed/ diff --git a/.issueops/issues/12/intent.md b/.issueops/issues/12/intent.md new file mode 100644 index 0000000..1a8eb5c --- /dev/null +++ b/.issueops/issues/12/intent.md @@ -0,0 +1,51 @@ +# 요청자 의도 계약 + +- lifecycle: io-21fd3b1e1f06 +- issue: https://github.com/m16khb-org/galpi/issues/12 +- intent_class: standard + +## 원문 요청 +google 로그인을 필수로하고잇는데 google drive에 녹음본을 올리고 거기있는 녹음본을 기반으로 전사 -> 화자분리 -> ai 증강을 하게 할수있어? / 구글드라이드 사용하기위해 온보딩절차를 만들고 그절차에 사용자 구글드라이브에 갈피용 폴더를 만들고 거기를 사용하게 하는거어때? / 갈피 앱과 웹에서는 google drive에 파일을 올리고 받고 수정할 수 있어야해 / 갈피웹은 아직 고려하지마 추후 만들 예정이야 / (수정 대상 답) 전사 결과 갱신 / (결과 업로드 답) 자동 업로드 / (Drive 앱으로 올린 녹음 처리 답) 이건 더 조사를 해봐 → 조사 결과 데스크톱 Google Picker로 가져오기 제안 / Cloud 프로젝트와 OAuth 클라이언트 ID 이거는 갈피전용을 쓰는 거야?? aside mcp를 활용해서 네가 만들어봐 / (이슈 생성 제안: 온보딩, 갈피 폴더, Picker로 가져오기, 결과 자동 업로드와 갱신, 충돌 감지) 로 이슈만들고 진행해줘 base는 main 브랜치로 / 앱 게시는 네가 확인해서 게시해줘 테스트유저말고 모두 쓸수있게해야해 / (클라이언트 보안 비밀번호 질문) 시크릿을상수로넣어도돼?? / 아니다 소스에 넣는거보다 auth gateway에서 받는게 좋겠어 + +## 해석 +Galpi 데스크톱 앱에 Google Drive 연동을 추가한다. 로그인(auth-gateway) 뒤 건너뛸 수 있는 Drive 연결 온보딩에서 Galpi 전용 Cloud 프로젝트(galpi-511214)의 데스크톱 OAuth 클라이언트로 drive.file 동의를 받아 auth-gateway 대행으로 코드를 교환하고, refresh token을 OS 비밀 저장소에 두고, 내 Drive에 Galpi 폴더를 만든다(있으면 재사용, 지워졌으면 다시 만든다). 'Drive에서 가져오기'는 데스크톱 Google Picker(trigger_onepick)를 시스템 브라우저로 열어 녹음 파일을 고르게 하고, 연결 계정과 같은 계정인지 확인한 뒤 파일을 기기로 내려받아 기존 전사·화자 분리·AI 증강 흐름에 넣는다. Drive가 연결되어 있으면 전사·AI 증강 결과 파일을 Galpi 폴더에 자동으로 올리고, 같은 회의를 다시 처리하면 새 파일을 만들지 않고 기존 Drive 파일을 갱신한다. 갱신 직전에 Drive 파일이 마지막 동기화 이후 바뀌었으면 덮어쓰지 않고 사용자에게 알린다. Galpi 웹은 범위 밖이다. OAuth 앱은 테스트 사용자 제한 없이 모두 쓸 수 있도록 프로덕션으로 게시한다(콘솔 설정과 m16khb.dev 정책 페이지, 코드 밖 작업). + +## 성공 기준 +- 로그인하지 않은 상태에서 새 Drive Tauri 명령은 AUTH_REQUIRED로 거부된다(Rust application 테스트). +- Drive 연결: 시스템 브라우저 drive.file 동의 → 루프백·PKCE 코드 교환 → refresh token을 OS 비밀 저장소에 저장 → Galpi 폴더 생성 또는 재사용 → 연결 계정 이메일 표시(어댑터 가짜 서버 테스트 + 실제 Google 스모크). +- Drive 연결 해제: 토큰 폐기를 시도하고 결과와 관계없이 로컬 토큰과 폴더 기록을 지운다(application 테스트). +- Drive에서 가져오기: Picker 콜백의 picked_file_ids와 code를 처리하고, 다른 계정으로 고르면 거부하며, 파일을 임시 경로로 스트리밍 다운로드한 뒤 기존 전사 흐름이 그 로컬 경로로 실행된다. 취소하면 부분 파일이 남지 않는다(테스트 + 실제 스모크). +- Drive가 연결되어 있으면 전사 완료와 AI 증강 완료 때 결과 파일이 Galpi 폴더 아래에 올라가고, 같은 회의를 다시 처리하면 같은 Drive 파일 ID가 갱신된다(가짜 서버 테스트 + 실제 스모크). +- 갱신 직전 Drive 파일 버전이 마지막 동기화 값과 다르면 덮어쓰지 않고 충돌을 사용자에게 알린다(application 테스트). +- Drive 업로드 실패는 로컬 결과 생성을 실패로 만들지 않고 별도 상태로 표시된다. +- 앱 소스에는 클라이언트 보안 비밀번호가 없고, 코드 교환과 토큰 갱신은 auth-gateway의 POST /drive/native/token(gateway 세션 bearer)으로만 한다. invalid_grant일 때만 Drive 연결을 끊고, gateway 미도달은 Drive 작업 실패로 표시한다(가짜 서버 테스트). +- bun run check, bun test, cargo fmt/clippy/test 게이트가 통과한다. +- OAuth 동의 화면 게시 상태가 프로덕션이고, 테스트 사용자가 아닌 계정도 동의할 수 있다(콘솔 확인). + +## 비목표 +- Galpi 웹(추후 별도 작업). +- drive.readonly 등 제한 범위 사용, Drive 폴더 전체 자동 동기화. +- 로컬 녹음 원본 오디오의 자동 업로드. +- Drive에서 고른 원본 파일의 이동·이름 변경·삭제. +- 앱 안의 전사 본문 편집기. + +## 제약 +- Google Cloud 프로젝트 galpi-511214, 데스크톱 OAuth 클라이언트 Galpi Desktop, 범위는 drive.file 하나. +- 데스크톱 Picker 요청에는 drive.file만 넣을 수 있고 prompt=consent, trigger_onepick=true가 필수다. +- 클라이언트 보안 비밀번호는 auth-gateway 서버 설정에만 둔다(선행 이슈 m16khb-org/auth-gateway#10). 앱은 client ID만 상수로 갖는다. +- 새 기능 명령은 Application 메서드 첫 줄에 self.access.require()?를 둔다(ADR 2026-10-09-auth-gateway-google). +- 전사 입력은 로컬 경로(TranscriptionRequest.input_path)로 유지하고 워커·프로토콜은 바꾸지 않는다. +- Drive refresh token은 기존 비밀 저장소 경로(Windows Credential Manager, macOS 0600 설정 파일)를 쓴다. + +## 모호함 +- resolved: 수정 대상은 전사 결과 갱신(사용자 답). +- resolved: 결과는 자동 업로드(사용자 답). +- resolved: Drive 앱 등 외부로 올린 녹음은 데스크톱 Google Picker로 가져온다(공식 문서 조사 + 실제 Picker 스모크로 picked_file_ids 확인). +- resolved: Cloud 프로젝트는 Galpi 전용 galpi-511214(사용자 답, 생성 완료). +- resolved: OAuth 앱은 프로덕션 게시(사용자 지시), 정책 페이지는 m16khb.dev portfolio-router(사용자 선택). +- deferred: 충돌 시 처리 방식(충돌 사본 업로드 또는 사용자 선택)과 Drive 안 회의별 폴더 이름 규칙은 계획 단계에서 정한다. +- deferred: 온보딩 refresh token이 이후 별도 Picker 흐름에서 고른 파일에도 접근하는지 구현 중 실측한다. +- resolved: 클라이언트 보안 비밀번호는 auth-gateway가 교환·갱신을 대행(사용자 결정, auth-gateway#10). + +## 읽는 규칙 +이 문서는 요청자 의도 계약이다. 원격 이슈 본문은 구현 계약이다. 두 문서가 충돌하면 구현을 시작하지 말고 충돌한 줄을 인용해 blocker로 보고한다. diff --git a/.issueops/issues/12/plan-review.md b/.issueops/issues/12/plan-review.md new file mode 100644 index 0000000..5e69ee6 --- /dev/null +++ b/.issueops/issues/12/plan-review.md @@ -0,0 +1,40 @@ +# 계획 검토 기록 + +## 1차: 수정 요청 + +- 로컬 원본의 회의 키를 출력 폴더로 정해, 같은 로컬 오디오를 다시 전사하면 출력 폴더가 '{stem} 2'로 바뀌어 Drive에 새 회의 폴더가 생기는 결함을 지적했다. 원본 경로 기반 키로 바꾸도록 요구했다. +- 브라우저 대기 전에 받은 gateway bearer가 최대 5분 대기 중 만료돼 교환이 401로 실패하는 결함을 지적했다. +- Drive에서 가져온 뒤 마이크 녹음을 전사하면 남은 driveSourceId 때문에 DRIVE_SOURCE_MISMATCH로 전사가 막히는 회귀를 지적했다. +- 호스트 bearer()가 gateway 거부로 세션을 지워도 화면은 로그인 상태로 남는 불일치를 지적했다. +- 가져온 녹음의 수정 시각이 가져온 시각이라 회의록 날짜가 틀어지는 결함을 지적했다. +- 연결 대기를 취소할 UI가 없고, Picker 대기 중 #cancel-button이 JOB_NOT_FOUND로 실패하는 결함을 지적했다. +- 증강 뒤 동기화에 증강 작업 ID를 넘기면 ARTIFACT_NOT_FOUND가 되는 모호함을 지적했다. +- 연결 해제와 진행 중 저장소 쓰기의 경합으로 토큰이 되살아날 수 있는 결함을 지적했다. + +## 2차: 수정 요청 + +- 2차 델타 리뷰: 1라운드 지적 8건 중 6건(회의 키, bearer 시점, driveSourceId, 수정 시각, 증강 뒤 동기화 ID, 해제 경합)은 해결됐다고 확인했다. +- AUTH_REQUIRED 뒤 AccessController.check()는 signedIn 상태에서 아무 일도 하지 않아 로그인 화면으로 돌아가지 않는다고 지적했다(access-machine에 sessionExpired 전이 필요). +- 가져오기 중 #cancel-button이 가져오기 작업 ID와 busy 상태에 연결되는 계약이 없어 버튼이 숨거나 JOB_NOT_FOUND가 난다고 지적했다. +- #drive-cancel-button을 두 곳에 같은 id로 두면 설정에서 연결할 때 취소 버튼이 나타나지 않는다고 지적했다. + +## 3차: 수정 요청 + +- 3차 델타 리뷰(anthropic/claude-opus-5-5, xhigh, 새 omp 세션): AUTH_REQUIRED 복귀(sessionExpired)와 취소 버튼 id 분리는 고쳐졌다고 확인했다. +- 가져오기 중 #cancel-button은 부모 #job-panel이 driveImport에서 숨겨지고 data-status가 completed로 남아 실제로 보이지 않는다고 지적했다. +- 다른 작업 실행 중에 Drive 가져오기를 누르면 begin이 실행 중 작업의 jobId와 busy 상태를 덮어써 취소가 JOB_NOT_FOUND로 끝나는 새 퇴행을 지적했다(가져오기 버튼을 busy·녹음 중 비활성으로). + +## 4차: 수정 요청 + +- 4차 델타 리뷰(anthropic/claude-opus-5-5, xhigh, 새 omp 세션): 가져오기 중 패널·취소 버튼 표시(A)와 busy 중 가져오기 버튼 비활성(B)은 고쳐졌다고 확인했다. +- 가져오기가 끝나도 completeJob·handleFailure가 없어 #job-panel이 running으로 남고 실패 이유가 패널에 표시되지 않으며 제목이 전사 문구로 남는 새 회귀를 지적했다. + +## 5차: 수정 요청 + +- 5차 델타 리뷰(anthropic/claude-opus-5-5, xhigh, 새 omp 세션): 가져오기 실패·취소 정리와 busy 해제, Drive 제목 분기는 고쳐졌다고 확인했다. +- 성공 시 completeJob을 쓰면 완료 CSS가 제목을 숨기고 '전사 완료' 요약 행을 보여 주며, completeJob이 TranscriptionResult를 받아 tsc가 실패한다고 지적했다(성공 시 패널을 숨기는 방식 요구). + +## 6차: 통과 + +- 6차 델타 리뷰(anthropic/claude-opus-5-5, xhigh, 새 omp 세션; 5라운드 안에 끝나지 않아 5차 지적 2건을 반영한 뒤 한 번 더 독립 검토함): 가져오기 성공 시 completeJob 대신 initialJobState로 되돌리고 renderDriveImported가 패널을 숨겨 '전사 완료'가 보이지 않으며, tsc 타입 불일치도 없어졌다고 판정했다. +- begin·renderJob·setBusy 순서, 이벤트 필터, 취소 경쟁을 코드로 확인해 새 필수 결함이 없다고 판정했다. 비차단 메모: Drive 가져오기 성공 때 setAudio(path)를 renderDriveImported 앞에서 함께 부를 것, 남은 jobKind 때문에 다음 전사 시작 직후 제목이 잠깐 틀릴 수 있음(기존 코드와 같은 종류). diff --git a/.issueops/issues/12/plan.md b/.issueops/issues/12/plan.md new file mode 100644 index 0000000..7923ab8 --- /dev/null +++ b/.issueops/issues/12/plan.md @@ -0,0 +1,228 @@ +# 계획: Google Drive 녹음 가져오기와 전사 결과 Drive 동기화 (galpi#12) + +- Lifecycle ID: `io-21fd3b1e1f06` +- 이슈: https://github.com/m16khb-org/galpi/issues/12 +- 브랜치: `12-google-drive-sync` (base `main` @ `4c24a2479fc4bf294ffeecc81fd06ea7438894fe`) +- 선행 이슈: m16khb-org/auth-gateway#10 (`io-4bf3aa5bab5f`, `POST /drive/native/token`). 이 계획의 단위·application·어댑터 테스트는 가짜 HTTP 서버로 그 계약을 고정하므로 gateway 배포 전에도 구현·검증할 수 있다. 실제 Drive 스모크만 배포된 gateway가 필요하다. +- 사용자 요청 범위: 이슈 생성, 계획, 구현, 검증, 커밋, push, draft PR 발행, execution complete. merge와 배포는 범위 밖이다. +- 세션: 워크트리 준비 뒤 `issueops` 라우터의 환경별 자동 세션 인계(준비 세션이 Herdr 안이므로 Herdr 우선)를 적용한다. 인계는 실행 위치만 바꾸며 승인 범위를 바꾸지 않는다. + +## TL;DR + +- 요약: Rust 호스트에 Drive 연결(시스템 브라우저 `drive.file` 동의 + gateway 대행 토큰 교환), 데스크톱 Picker 가져오기(스트리밍 다운로드), 회의 결과 업로드·갱신(Drive `appProperties`와 `sha256Checksum`으로 무상태 충돌 판정)을 추가하고, 프런트엔드에 온보딩·연결 상태·가져오기·동기화 상태 UI를 붙인다. +- 산출물: Rust domain/application/adapter/inbound 변경과 테스트, 프런트엔드 domain/application/ui/adapter 변경과 테스트, README 두 벌·INSTALL·DESIGN·docs/ARCHITECTURE 갱신. +- 규모: Large. 병렬: 아니오(한 세션이 순서대로 구현, 조사용 하위 에이전트만 허용). 핵심 경로: T1 → T2 → T3 → T4 → T5 → T6 → T7 → T8. + +## 적용되는 결정과 주의사항 + +| 문서 | 항목 | 이 계획에 미치는 제약 | +|---|---|---| +| `docs/ARCHITECTURE.md` §2·§145–149, `.issueops/architecture/overview.md` | 포트 소유·새 외부 능력 절차 | 새 포트는 `application/ports.rs`에 두고 outbound 구현 → `composition.rs` 배선 → `application/tests.rs` 가짜 포트를 같은 변경에서 추가한다. | +| `.issueops/adr/2026-08-23-adopt-hexagonal-ports-...` | 안쪽 의존 규칙 | Drive 값 객체·충돌 판정 규칙은 framework 없는 `domain/drive.rs`에 두고 reqwest·Tauri는 어댑터에만 둔다. | +| `.issueops/adr/2026-10-09-auth-gateway-google.md` | 기능 명령의 로그인 게이트 | 새 Drive 기능 명령(연결, 가져오기, 동기화)은 Application 메서드 첫 줄에서 `self.access.require()?`를 호출한다. 연결 해제·취소·상태 조회는 막지 않는다. | +| `.issueops/adr/2026-10-09-sign-in-with-chatgpt-assistantsettings.md` | 호스트가 토큰 갱신을 소유, tokio Mutex로 직렬화, 회전 토큰 즉시 저장 | Drive 토큰 갱신도 `application/drive.rs`가 `tokio::sync::Mutex`로 직렬화하고 갱신 결과를 즉시 저장한다. 토큰은 IPC 타입에 들어가지 않는다. | +| ADR "Windows Credential Manager에 긴 비밀을 조각으로 저장한다"(2026-10-09) | 2,560바이트 초과 비밀은 조각 저장 | Drive 토큰 레코드를 기존 비밀 저장소 trait로 저장하면 Windows에서 자동으로 조각 저장된다. 새 저장 경로를 만들지 않는다. | +| CAUTION "새 Secret 값을 추가하면 LocalSettingsStore의 네 match 분기와 is_empty를 함께 고친다"(2026-10-09) | 네 match 분기와 `is_empty` | `DriveSession` 변형 추가 때 `settings.rs`의 네 비밀 함수(L89–110, L126–179, L183–203, L211–235)와 `LocalSettings` 필드(L298–300 옆), `is_empty()`(L304–)를 모두 고치고, 빈 설정에서 저장한 Drive 세션이 재시작 뒤 남는 테스트를 추가한다. | +| `.issueops/cautions/2026-10-09-chatgpt-responses.md` | 토큰·Authorization·인가 URL 비노출, 한도·인증 오류 뒤 재전송 금지 | Drive 어댑터는 토큰·코드·`state`·인가 URL을 로그·오류 문구에 넣지 않고, 실패한 요청을 자동 재시도하지 않는다. `DriveTokens`의 `Debug`는 값을 가린다. | +| `.issueops/cautions/2026-08-23-tauri-frontend-renders-in-a-plain-browser-...` | 이벤트 구독 후 invoke | 이번 기능은 새 Tauri 이벤트를 추가하지 않는다(모든 명령이 단일 await 결과). 기존 bind-first 시작 순서를 유지한다. | +| `.issueops/cautions/2026-10-09-qa-ipc.md` | 브라우저 QA는 IPC를 가짜로 주입 | 화면 QA는 Vite 개발 서버 + 가짜 `__TAURI_INTERNALS__`로 온보딩·연결·가져오기·충돌 표시를 확인한다. | +| `.issueops/cautions/2026-08-23-dom-visibility-is-not-pixel-visibility-...` | DOM 가시성 ≠ 픽셀 가시성 | 새 상태 문구는 DOM 테스트 외에 실제 브라우저에서 `elementsFromPoint`로 가려지지 않는지 확인한다. | +| `.issueops/cautions/2026-10-09-readme.md` | README 두 벌 동시 갱신 | Drive 사용법과 저장 위치를 `README.md`와 `README.en.md`에 같은 변경 세트로 쓴다. | +| `.issueops/cautions/2026-10-09-windows-env-clear-expect-dead-code.md` | 경로 정경화는 `paths::canonical`만 | 다운로드 경로와 포함 검사는 `paths::canonical`을 쓴다. 한 플랫폼 전용 항목이 생기면 `cfg_attr` 조건을 정확히 건다. | +| `AGENTS.md` CONVENTIONS / `DESIGN.md` / `.issueops/adr/2026-10-09-adopt-seed-css-tokens-...` | SEED 토큰만, 버튼은 `src/ui/seed.ts` recipe, 상태는 색+문구, 사용자 문구 한국어 | 새 UI는 `--seed-*` 토큰과 기존 클래스만 쓰고 hex·px를 추가하지 않는다(`src/styles.test.ts`가 검사). | +| `.issueops/OPEN_API_SPEC.md` | 엔드포인트·DTO 게이트 | 이 저장소에는 HTTP 엔드포인트가 없다(IPC만). 계약 변경은 IPC 명령 6개와 Zod 스키마이며 `docs/ARCHITECTURE.md` 표를 갱신한다. | +| `.issueops/CONSTITUTION.md` | 비밀 원문 금지, ETA 금지 | 토큰·클라이언트 보안 비밀번호를 문서·테스트 픽스처·응답에 넣지 않는다. 다운로드 진행은 단계만 알리고 남은 시간을 추정하지 않는다. | + +## 재사용하는 기존 구현 + +| 기존 구현 | 위치 | 재사용 방식 | +|---|---|---| +| 루프백 리스너 | `adapters/outbound/browser_sign_in/loopback.rs` (`Loopback::bind_preferring`, `redirect_uri`, `wait`; `Callback::get`은 모든 쿼리 값을 보관) | Drive 동의와 Picker 콜백을 그대로 받는다. `picked_file_ids`·`code`·`state`·`error`를 `Callback::get`으로 읽는다. 선호 포트도 같은 `PREFERRED_PORT`를 쓴다(점유 시 OS 할당 포트로 폴백하므로 gateway 로그인과 동시에 열려도 안전하다). | +| PKCE·난수 | `browser_sign_in/pkce.rs` (`random_urlsafe`, `code_challenge`) | Drive 인가의 verifier·state 생성 | +| 브라우저 열기 | `browser_sign_in/browser.rs` (`BrowserOpener`, `TauriBrowser`) | 인가 URL과 Picker URL을 시스템 브라우저로 연다. 새 capability 권한이 필요 없다(Rust에서 직접 연다). | +| `SignInCodes` | `browser_sign_in/mod.rs` | Drive 전용 코드(`DRIVE_CONNECT_FAILED`, `DRIVE_CONNECT_TIMEOUT`)로 인스턴스화 | +| 가짜 HTTP 서버 | `browser_sign_in/fake_server.rs` (`FakeServer`, `Recorded`) | gateway 대행 엔드포인트와 Drive REST를 흉내 내는 어댑터 테스트. 업로드 본문 검증을 위해 `Recorded`에 요청 헤더(`content-type`) 기록이 필요하면 필드 하나를 추가한다. | +| gateway 어댑터 패턴 | `adapters/outbound/gateway/mod.rs` (`Tuning`, `GatewayOAuthAdapter::with_tuning`, `PRODUCTION_URL`, 디버그 전용 `GALPI_AUTH_GATEWAY_URL`) | Drive 어댑터도 같은 `Tuning` 형태로 테스트에서 주소를 바꾼다. gateway 주소는 기존 `gateway_url()`을 재사용하도록 `pub(crate)`로 연다(새 상수를 만들지 않는다). | +| 로그인 게이트 | `application/gateway.rs` (`AppAccessGate`) | `require()`를 그대로 쓰고, gateway bearer가 필요하므로 유효한 access token을 돌려주는 `bearer()`를 추가한다(아래 T2). | +| 비밀 저장소 | `Secret` 열거형과 `SecretStore` trait(outbound 어댑터의 `secrets.rs`), `LocalSettingsStore`(`settings.rs`) | `DriveSession` 변형과 새 `drive.rs`(기존 `settings` 모듈의 `gateway.rs` 패턴)로 Drive 토큰과 연결 정보를 저장한다. | +| 작업 레지스트리 | `application/jobs.rs` (`JobRegistry::claim_with_id`, `register`, `artifacts`) | 다운로드는 작업 슬롯을 점유하고 취소를 받는다. 동기화는 등록된 산출물(`Artifacts`)을 읽는다. | +| 산출물 집합 | `domain/artifact.rs` (`Artifacts`, `ArtifactKind`, `minutes_path`) | 업로드 대상(`srt`, `speaker_text`, `minutes`)을 `path_for`로 고른다. `checkpoint`는 올리지 않는다. | +| 앱 경로 | `adapters/outbound/paths.rs` (`AppPaths.cache`, `canonical`, `sanitize_name`) | 다운로드 임시 폴더 `cache/drive-imports//`와 파일 이름 정리 | +| 프런트 상태 기계 패턴 | `src/application/access-machine.ts`, `chatgpt-machine.ts` | 새 `drive-machine.ts`도 불변 reducer + 같은 위치 테스트 | +| 프런트 컨트롤러 패턴 | `src/ui/chatgpt-controller.ts`, `access-controller.ts` | 새 `drive-controller.ts`가 같은 의존 주입·dispatch 구조를 따른다. | +| Zod 경계 | `src/adapters/tauri-backend.ts` | 새 응답 스키마를 같은 파일에 둔다. | + +새로 만드는 것과 이유: + +- Drive REST 클라이언트: 기존 코드에 Drive 호출이 없다. reqwest(`native-tls`, `form`)는 그대로 쓰고 기능을 추가하지 않는다. JSON은 기존처럼 `serde_json`으로 직렬화하고, multipart 본문은 수동으로 만들며, 다운로드는 `Response::chunk()`로 스트리밍한다(`stream` 기능 불필요). +- `DriveAccounts`(application): ChatGPT의 `ChatGptAccounts`와 같은 역할이지만 저장 형태·갱신 계약(gateway 대행, `invalid_grant`만 연결 해제)이 달라 공유하지 않는다. + +## 성능 영향 + +- 전사 핫패스(워커, JSONL 이벤트, CPAL 녹음)는 바뀌지 않는다. 전사 명령의 결과도 바뀌지 않으며, Drive 업로드는 전사 완료 뒤 별도 IPC 호출로 실행된다. +- 다운로드: `chunk()` 단위로 `tokio::fs::File`에 써서 메모리 사용량이 파일 크기와 무관하게 청크 크기로 제한된다. 취소는 청크 사이에서 확인한다. +- 업로드: 산출물(텍스트, 수 MB 미만)을 메모리로 읽어 multipart 한 번에 보낸다. 5 MiB를 넘으면 `DRIVE_UPLOAD_TOO_LARGE`로 그 파일만 실패시킨다(Drive multipart 한도). 파일마다 메타데이터 조회 1회 + 업로드 1회이고, 회의당 최대 3개 파일이다. +- 토큰: access token 만료 5분 전부터만 갱신한다. gateway bearer도 만료 60초 전부터만 갱신한다. 평상시 요청마다 추가 왕복이 없다. +- 측정: 실제 스모크에서 50 MB 이상 녹음 다운로드 중 앱 메모리(Activity Monitor의 Galpi 프로세스 실메모리)가 다운로드 전 대비 50 MB 이상 늘지 않는지 기록한다. + +## 하위 호환성과 side effect + +- IPC: 기존 28개 명령의 이름·인자·응답은 바뀌지 않는다. `TranscriptionRequest`에 선택 필드 `driveSourceId`(`#[serde(default)]`)만 추가하므로 기존 프런트 요청은 그대로 파싱된다. 새 명령 7개를 추가한다(`load_drive_status`, `connect_drive`, `cancel_drive_operation`, `disconnect_drive`, `dismiss_drive_onboarding`, `import_from_drive`, `sync_meeting_to_drive`). `docs/ARCHITECTURE.md`의 명령 수(28 → 35)와 포트 수(15 → 18)를 고친다. +- 설정 파일: `settings.json`에 `driveEmail`, `drivePermissionId`, `driveFolderId`, `driveOnboardingDismissed`, `driveSession`(macOS 평문 비밀, 기존 gateway와 같은 수준), `driveSessionStored`를 `#[serde(default)]`로 추가한다. 이전 버전 파일은 그대로 읽히고, 이전 버전 앱이 새 파일을 읽어도 모르는 필드는 무시된다(`LocalSettings`는 `#[serde(default, rename_all = "camelCase")]`이고 `deny_unknown_fields`가 없다, `settings.rs` L270–271). +- Windows Credential Manager: 새 항목 `com.m16khb.galpi:drive-session`이 생긴다. +- 원격 side effect(사용자 Drive): 연결 시 `Galpi` 폴더 1개, 회의마다 하위 폴더 1개와 결과 파일 최대 3개를 만든다. 사용자가 고른 원본은 읽기만 하며 옮기거나 바꾸지 않는다. 연결 해제는 Google 토큰 폐기를 요청할 뿐 Drive 파일을 지우지 않는다. +- 로컬 side effect: 가져온 녹음은 `AppPaths.cache/drive-imports//`에 남고 앱 시작 때 이 폴더 전체를 지운다. 다운로드 실패·취소 때는 해당 `` 폴더를 즉시 지운다. +- auth-gateway 계약 의존: `POST {gateway}/drive/native/token`(auth-gateway#10). gateway가 아직 배포되지 않으면 연결이 `DRIVE_UNAVAILABLE`로 실패할 뿐 기존 기능에 영향이 없다. +- 롤백: 이 PR을 되돌리면 새 설정 필드는 무시되고 Drive 비밀 항목만 남는다. 남은 항목은 기능에 영향이 없다. +- LLM 프롬프트 본문 변경 없음. + +## 설계 + +### 도메인 (`src-tauri/src/domain/drive.rs`, 신규) + +- `DriveTokens { access_token, refresh_token, expires_at }`: `Serialize/Deserialize`는 저장소 전용, 수동 `Debug`로 값을 가린다. `needs_refresh(now, margin) -> bool`. +- `DriveConnection { email: String, permission_id: String, folder_id: String }`: 비밀 아님. +- `DriveStatus`(IPC 직렬화, `tag = "state"`): `Disconnected { onboarding_dismissed: bool }`, `Connected { email: String }`. +- `DriveFileId(String)`: 비어 있지 않고 `[A-Za-z0-9_-]{1,200}`만 허용하는 값 객체(경로 주입 방지). `picked_file_ids` 파싱(`parse_picked_ids(&str) -> Result, AppError>`)도 여기 둔다. +- `MeetingKey`: `drive:`(Drive에서 가져온 녹음) 또는 `local:`. 원본 경로는 전사면 이미 정경화된 `Artifacts.source_audio`(`transcription.rs` L180), 전사문 가져오기면 정경화한 `request.input_path`다. 출력 폴더는 쓰지 않는다: `create_meeting_directory`(`paths.rs` L193–223)가 Galpi 녹음이 아닌 입력에는 실행마다 `{stem}`, `{stem} 2`, …를 새로 만들기 때문이다. `appProperties` 값 길이 한도(키+값 124바이트) 안에 들어간다. Drive 회의 폴더 이름은 원본 파일의 stem(Drive 원본이면 Drive 파일 이름의 stem)이다. +- `SyncDecision`: 순수 함수 `decide(remote: Option, local_sha256: &str) -> SyncDecision` — 원격 없음 → `Create`; 원격 `sha256Checksum == appProperties.galpiSha256 == local` → `Unchanged`; 원격 `sha256Checksum == appProperties.galpiSha256 != local` → `Update`; 원격 `sha256Checksum`이 없거나 `galpiSha256`과 다르면 → `Conflict`. `overwrite`가 참이면 `Conflict` 대신 `Update`. +- `DriveSyncState`(IPC): `Uploaded`, `Updated`, `Unchanged`, `Conflict`, `Failed { message }`; `DriveSyncFile { kind: ArtifactKind, state, web_view_link: Option }`; `DriveSyncResult { folder_link: Option, files: Vec }`. `ArtifactKind`에 `Serialize`를 추가한다(현재 `Deserialize`만, `domain/artifact.rs` L19). +- `DriveImport { job_id, input_path, drive_file_id, name }`(IPC 결과). +- `parse_rfc3339_utc` 같은 날짜 파싱은 domain에 두지 않는다. Drive `modifiedTime` 파싱은 어댑터가 기존 `chrono` 의존으로 한다(필요하면 기존 `chrono` 의존의 기능 플래그만 추가하고 새 크레이트는 넣지 않는다). + +### application + +- `ports.rs`에 포트 3개: + - `DriveAuthPort`: `authorize(&self, request: DriveAuthorize, cancel) -> Result`(브라우저 동의 또는 Picker: `picker: bool`, `login_hint: Option`; 결과는 `code`, `redirect_uri`, `verifier`, `picked: Vec`), `exchange(bearer, code, verifier, redirect_uri) -> Result`, `refresh(bearer, refresh_token) -> Result`, `revoke(refresh_token)`(최선 노력, Google `https://oauth2.googleapis.com/revoke` 직접 호출). + - `DriveFilesPort`: `whoami(access) -> DriveAccount{email, permission_id}`, `ensure_root_folder(access, known: Option<&str>) -> String`, `ensure_meeting_folder(access, root, key: &MeetingKey, name) -> RemoteFolder{id, link}`, `find_artifact(access, folder, key, kind) -> Option`, `create_file(...)`, `update_file(...)`, `file_metadata(access, id) -> PickedFile{name, mime_type, size, can_download, modified_time}`, `download(access, id, dest, modified_time, cancel) -> Result<(), AppError>`(완료 직전 `.part` 파일의 수정 시각을 Drive `modifiedTime`으로 맞춘다: 증강은 `recorded_on`(`refinement.rs` L72–82)에서 원본의 수정 시각을 회의 날짜로 쓰기 때문이다. `std::fs::File::set_modified` 사용, rust-version 1.88). + - `DriveSessionStore`: `load_status`, `load_connection`, `load_tokens`, `save_connection(conn, tokens)`, `replace_tokens`, `save_folder_id(id)`(연결 정보 중 폴더 ID만 바꾸는 좁은 쓰기), `dismiss_onboarding`, `clear`. +- `DriveTokenFailure { Rejected, AuthRequired, Unavailable }`: gateway 400 `invalid_grant` → Rejected, 401 → AuthRequired, 그 밖(503 `temporarily_unavailable`·`drive_unavailable`, 연결 실패, 형식 오류) → Unavailable. +- `AppAccessGate::bearer() -> Result`: `writes` 잠금 아래 저장 토큰을 읽고 `expires_at - 60 > now`면 그대로, 아니면 `auth.refresh` → 성공 시 `replace_tokens` 후 새 access token; Rejected → `store.clear()` + `SignedOut` + `AUTH_REQUIRED`; Unavailable → `GATEWAY_UNAVAILABLE`("로그인 서버에 연결하지 못해 Drive 작업을 할 수 없습니다."). 이를 위해 `AppAccessGate::new`에 `clock: Arc`를 추가하고 `composition.rs`(L70)와 테스트 생성부를 고친다. +- `application/drive.rs` `DriveAccounts`(ChatGptAccounts 구조): 필드 `auth`, `files`, `store`, `clock`, `store_lock: tokio::sync::Mutex<()>`(토큰 갱신과 모든 Drive 저장소 쓰기, 연결 해제의 `clear`를 직렬화), `operation_active: AtomicBool`, `operation_cancel: Mutex>>`(연결 대기 전용). gateway bearer는 값으로 받지 않고 `&AppAccessGate`를 받아 **gateway를 호출하기 직전마다** `access.bearer().await`로 구한다. 브라우저 대기(최대 `SIGN_IN_TIMEOUT` 5분, `loopback.rs` L20) 동안 gateway access token이 만료돼도 교환에는 갱신된 bearer를 쓰기 위해서다. 메서드: + - `status()`. + - `connect(access)`: 연결 대기 슬롯 점유(동시 실행 `DRIVE_BUSY`) → `authorize(picker=false, operation_cancel)` → `access.bearer()` → `exchange` → `whoami` → `ensure_root_folder(None)` → `store_lock` 아래 `save_connection`. `exchange` Rejected → `DRIVE_CONNECT_FAILED`. + - `access_token(access)`: `store_lock` 아래 연결이 남아 있는지 확인하고, 만료 5분 전이면 `access.bearer()` → gateway 갱신 → `replace_tokens`. Rejected → `clear()` 후 `DRIVE_RECONNECT_REQUIRED`("Drive 연결이 만료되었습니다. 다시 연결해 주세요."); AuthRequired → `AUTH_REQUIRED`; Unavailable → `DRIVE_UNAVAILABLE`. `force_refresh(access)`는 만료와 무관하게 같은 절차를 한 번 수행한다(Drive 401 처리용). + - `pick(access, cancel)`: 연결 필수(`DRIVE_NOT_CONNECTED`) → `authorize(picker=true, login_hint=email, cancel)` → `access.bearer()` → `exchange` → `whoami`가 저장된 `permission_id`와 다르면 새 refresh token을 `revoke`하고 `DRIVE_ACCOUNT_MISMATCH` → 같으면 `store_lock` 아래 연결이 아직 남아 있을 때만 새 토큰으로 `replace_tokens`(가장 최근 동의를 보관; 그 사이 연결이 해제됐으면 새 토큰을 `revoke`하고 `DRIVE_NOT_CONNECTED`) → `picked`가 정확히 1개인지 확인(0개 `CANCELLED`, 2개 이상 `DRIVE_PICK_INVALID`). `cancel`은 호출자가 넘긴 작업 슬롯의 수신기다. + - `disconnect()`: 연결 대기 취소 → `store_lock` 획득 → 저장 토큰 `revoke`(결과 무시) → `clear()`. 이후 진행 중이던 동기화·Picker가 저장소에 쓰려 하면 연결이 없으므로 쓰지 않는다. + - `cancel()`: 연결 대기 취소. +- `Application`(use_cases.rs)에 `drive: DriveAccounts`와 `drive_meetings: Mutex>`, `drive_imports: Mutex>`를 추가하고 새 메서드: + - `load_drive_status()`, `dismiss_drive_onboarding()`, `cancel_drive_operation()`, `disconnect_drive()`: 게이트 없음. + - `connect_drive()`: `require()` → `drive.connect(&self.access)`. + - `import_from_drive(job_id)`: `require()` → **먼저** `jobs.claim_with_id(job_id)`로 작업 슬롯을 점유하고, 그 취소 수신기를 Picker 대기와 다운로드에 함께 쓴다(기존 `#cancel-button` → `cancel_job` 하나로 두 단계 모두 취소, Picker 대기 중에도 `JOB_NOT_FOUND`가 나지 않는다) → `drive.pick(&self.access, &mut cancel)` → `file_metadata`로 `can_download`와 MIME(`audio/*` 또는 `video/*`, Google 문서 형식 거부 → `DRIVE_UNSUPPORTED_FILE`) 확인 → `cache/drive-imports//`로 `download` → 결과 경로를 `drive_imports`에 기록 → `DriveImport` 반환. 실패·취소 때 `` 폴더 삭제는 어댑터가 한다. + - `transcribe`(기존): `request.drive_source_id`가 있으면 그 값이 `drive_imports`에 기록된 경로·ID와 일치할 때만 `MeetingKey::Drive`로, 없으면 `MeetingKey::Local(source_audio)`로 `drive_meetings`에 기록한다(완료 후). 불일치는 `DRIVE_SOURCE_MISMATCH`로 전사 전에 거부. + - `import_transcript`(기존): 완료 후 정경화한 입력 경로로 `MeetingKey::Local`을 기록. + - `sync_meeting_to_drive(job_id, overwrite: bool)`: `job_id`는 **전사 또는 전사문 가져오기 작업의 ID**다(증강 결과는 `register_minutes(target, …)`로 그 작업에 등록되므로 증강 뒤에도 같은 ID를 쓴다). `require()` → 연결 필수 → `jobs.artifacts(job_id)`와 `drive_meetings[job_id]` → `drive.access_token(&self.access)` → 루트 폴더 확인(`ensure_root_folder(Some(saved))`, 바뀌면 `store_lock` 아래 연결이 남아 있을 때만 `save_folder_id`) → 회의 폴더(`appProperties.galpiMeeting = key`) → 산출물별로 `find_artifact` → `decide` → `create_file`/`update_file`(모두 `appProperties { galpiMeeting, galpiKind, galpiSha256 }` 포함) → 파일별 결과를 모은다. 한 파일의 실패는 `Failed`로 담고 나머지를 계속한다. Drive 401이면 `drive.force_refresh`로 한 번 갱신한 뒤 원래 요청을 다시 보내지 않고 위 오류 규칙으로 끝낸다. 토큰·연결 오류(`DRIVE_RECONNECT_REQUIRED`, `AUTH_REQUIRED`, `DRIVE_UNAVAILABLE`, `DRIVE_NOT_CONNECTED`)는 전체 `Err`로 돌려준다. 작업 슬롯은 쓰지 않는다(전사·증강과 병행 가능). +- 앱 시작 정리: `composition.rs`의 setup에서 `cache/drive-imports`를 지운다(실패는 무시, 로그만). + +### 어댑터 (`src-tauri/src/adapters/outbound/drive/`, 신규) + +- `mod.rs`: `DriveOAuthAdapter`(`DriveAuthPort`)와 `DriveRestAdapter`(`DriveFilesPort`), `Tuning { gateway_url, google_auth_url, google_revoke_url, drive_api_url, drive_upload_url, preferred_port, sign_in_timeout }`. 프로덕션 상수: `CLIENT_ID = "747212408320-1io6clrt6t568ot424pv54lepsvfl9pf.apps.googleusercontent.com"`(공개 값), `SCOPE = "https://www.googleapis.com/auth/drive.file"`. 클라이언트 보안 비밀번호는 없다. +- `authorize.rs`: 인가 URL 조립 — 공통 `client_id`, `redirect_uri`, `response_type=code`, `scope`, `code_challenge`(S256), `state`, `access_type=offline`, `prompt=consent`; 연결 때 `include_granted_scopes`는 넣지 않는다; Picker 때 `trigger_onepick=true`, `mimetypes=audio/mpeg,audio/mp4,audio/x-m4a,audio/aac,audio/wav,audio/x-wav,audio/flac,audio/ogg,video/mp4,video/quicktime`, `login_hint`. 콜백: `state` 불일치 → `DRIVE_STATE_MISMATCH`, `error=access_denied` → `CANCELLED`, 그 밖 `error` → `DRIVE_CONNECT_FAILED`, `code` 없음 → `DRIVE_CONNECT_FAILED`. +- `tokens.rs`: `POST {gateway}/drive/native/token`에 gateway access token bearer 헤더와 `Content-Type: application/json`을 붙인다. 본문은 `{"grant_type":"authorization_code","code","code_verifier","redirect_uri"}` 또는 `{"grant_type":"refresh_token","refresh_token"}`. 200 응답에서 `access_token`, `expires_in`, `refresh_token`(갱신 응답에 없으면 기존 값 유지). 오류 매핑은 위 `DriveTokenFailure`. +- `rest.rs`: Drive v3 호출. 모든 요청 `Authorization: Bearer`, `connect_timeout 10s`, 요청 `timeout 60s`(다운로드는 `timeout` 없이 청크 사이 읽기 제한 60s), 리다이렉트 정책 기본. + - `about?fields=user(emailAddress,permissionId)`. + - 루트 폴더 찾기: `files?q=` `mimeType='application/vnd.google-apps.folder' and trashed=false and appProperties has { key='galpiRoot' and value='1' }` `&fields=files(id)` → 없으면 `POST files` `{name:"Galpi", mimeType: folder, appProperties:{galpiRoot:"1"}}`. `known`이 있으면 먼저 `files/{id}?fields=id,trashed`로 확인하고 404·trashed면 찾기/만들기. + - 회의 폴더: `appProperties has { key='galpiMeeting' and value='' } and '' in parents and trashed=false` → 없으면 생성(`parents:[root]`, 이름 = 회의 폴더 이름). `webViewLink`를 함께 받는다. + - 산출물 찾기: `'' in parents and trashed=false and appProperties has { key='galpiKind' and value='' }` `fields=files(id,sha256Checksum,appProperties,webViewLink)`. + - 생성: `POST {upload}/files?uploadType=multipart&fields=id,webViewLink` multipart/related(경계 문자열은 `random_urlsafe`), 메타데이터 `{name, parents:[meeting], mimeType, appProperties}`. + - 갱신: `PATCH {upload}/files/{id}?uploadType=multipart&fields=id,webViewLink` 메타데이터 `{appProperties}`. + - MIME: srt `application/x-subrip`, txt `text/plain`, md `text/markdown`. + - `q` 값의 작은따옴표·역슬래시는 이스케이프한다(`MeetingKey`·ID는 값 객체로 이미 안전 문자만 허용). + - 다운로드: `files/{id}?alt=media`를 `.part`에 청크로 쓰고 완료 후 `rename`. 취소(`cancel` 수신)나 오류 때 상위 `` 폴더를 지운다. HTTP 403 `cannotDownloadFile`/`fileNotDownloadable` 등은 `DRIVE_DOWNLOAD_FAILED`. + - Drive API 오류 매핑: 401 → 어댑터는 `DRIVE_TOKEN_REJECTED`(내부 코드)를 돌려주고, `DriveAccounts`가 만료와 무관하게 gateway 갱신을 한 번 강제한다. 갱신이 Rejected면 연결 정보를 지우고 `DRIVE_RECONNECT_REQUIRED`, 성공하면 새 토큰을 저장하고 원래 요청은 다시 보내지 않은 채 `DRIVE_UNAVAILABLE`("잠시 뒤 다시 시도해 주세요")로 끝낸다(자동 재전송 금지 규칙). 403 `storageQuotaExceeded` → `DRIVE_STORAGE_FULL`, 404 → 호출 지점별 처리(폴더 재생성), 429·5xx → `DRIVE_UNAVAILABLE`. +- `drive.rs`(`settings` 모듈 아래, 기존 `gateway.rs` 구조): `DriveSessionStore for LocalSettingsStore`, 토큰은 `DriveSession` 변형(account `drive-session`). +- `secrets.rs`: `Secret` 열거형에 `DriveSession` 변형과 account 문자열 추가. +- `gateway/mod.rs`: `gateway_url()`을 `pub(crate)`로 열어 Drive 어댑터가 같은 주소(디버그 덮어쓰기 포함)를 쓴다. + +### inbound·composition + +- `adapters/inbound/tauri.rs`: 명령 7개(얇게 위임). `import_from_drive(job_id)`, `sync_meeting_to_drive(job_id, overwrite)`. +- `TranscriptionRequest`(`domain/job.rs` L24): `#[serde(default)] pub drive_source_id: Option`. +- `composition.rs`(기존 `.setup(|app| …)` L28 안): `DriveOAuthAdapter::new(browser.clone())?`, `DriveRestAdapter::new(paths)?`, `DriveAccounts::new(...)`, `AppAccessGate::new(gateway_auth, gateway_store, clock.clone())`, `Application::new(..., drive)`, `generate_handler!`에 7개 등록, setup에서 `drive-imports` 정리. + +### 프런트엔드 + +- `src/domain/drive.ts`(신규): `DriveStatus`, `DriveImport`, `DriveSyncResult`, `DriveSyncFile`, `DriveSyncState`. +- `src/domain/backend.ts` `BackendPort`: `loadDriveStatus`, `connectDrive`, `cancelDriveOperation`, `disconnectDrive`, `dismissDriveOnboarding`, `importFromDrive(jobId)`, `syncMeetingToDrive(jobId, overwrite)`; `startTranscription` 요청에 선택 `driveSourceId`. +- `src/adapters/tauri-backend.ts`: 위 명령의 `invoke`와 Zod 스키마(`driveStatusSchema`, `driveImportSchema`, `driveSyncResultSchema`). +- `src/application/drive-machine.ts`(신규, 불변 reducer + `drive-machine.test.ts`): 상태 `unknown → disconnected{onboarding} | connected{email}`; 작업 `idle | connecting | importing | syncing`(가져오기는 Picker 대기와 다운로드를 한 단계로 보여 준다: 둘 사이를 알리는 이벤트가 없기 때문이다); 동기화 결과(job별 마지막 `DriveSyncResult`), 오류 문구. 이벤트: `statusLoaded`, `connectStarted/Succeeded/Failed/Cancelled`, `onboardingDismissed`, `disconnected`, `importStarted/Imported/Failed`, `syncStarted/Finished/Failed`, `reconnectRequired`(→ disconnected, onboarding 다시 표시). +- `src/ui/drive-controller.ts`(신규): 시작 때 `loadDriveStatus`; `connect`, `cancel`, `disconnect`, `dismiss`, `importFromDrive`, `syncAfter(meetingJobId)`(연결 상태일 때 자동 호출), `overwrite(meetingJobId)`. 가져오기는 `AppController`가 넘긴 콜백 계약으로 작업 수명 주기 전체를 정리한다: 시작할 때 `onImportStarted(jobId)`(AppController가 `begin("driveImport", jobId)`로 busy 상태와 `this.job.jobId`를 설정), 성공 시 `onImported({ path, driveSourceId, name })`(오디오 선택 값을 교체하고, 전사 전용인 `completeJob`(`job-machine.ts` L95, `TranscriptionResult`를 받음)은 쓰지 않는다. 대신 `this.job = initialJobState`(L15)로 되돌리고 새 뷰 메서드 `renderDriveImported(name)`을 부른다. 이 메서드는 기존 `renderImportedTranscript`(`app-view.ts` L353–363)처럼 `transcriptionSummary = false`로 두고 `#job-panel`을 숨기며, `#audio-path`에 가져온 파일 이름을 표시한다. 그래서 완료 요약 행 "전사 완료"(`app-template.ts` L146, `styles.css` L1526·L1546)가 전사하지 않은 파일에 보이지 않는다), 실패·취소 시 `onImportFailed(error)`(AppController가 `handleFailure(error)`로 `#job-panel`을 `data-status="failed"`로 두고 `#error-message`에 이유를 표시; 취소는 `cancel()`의 `cancelJob`이 먼저 `cancelled`로 두고 뒤이은 실패를 `failJob`이 무시, `job-machine.ts` L107–116), 끝나면 성공·실패와 관계없이 `finally`에서 `onImportFinished()`(AppController가 `setBusy(null)`). `app-view.ts`의 `renderJobTitle`(L458–466)과 `busyLabel`(L624–627)에 `driveImport` 분기를 추가한다: 진행 "Drive에서 녹음을 가져오고 있습니다", 실패 "Drive에서 녹음을 가져오지 못했습니다", 취소 "Drive 가져오기를 취소했습니다", busy 라벨 "Drive 가져오기". Drive 명령이 `AUTH_REQUIRED`로 실패하면 `AccessController.expire()`를 부른다. `access-machine.ts`에 `signedIn`에서 `signedOut`으로 가는 새 이벤트 `sessionExpired`를 추가하고, `expire()`는 이 이벤트를 dispatch한 뒤 기존 `onClosed`를 호출해 로그인 화면으로 돌아간다(기존 `check()`는 `signedIn`에서 아무 일도 하지 않으므로 쓰지 않는다, `access-controller.ts` L31–33, `access-machine.ts` L41–42). +- `src/ui/controller.ts`: 오디오 선택 상태를 `audioPath` 하나에서 `{ path, driveSourceId? }` 한 값으로 바꾸고, 경로를 바꾸는 모든 지점(녹음 완료 콜백 L41–44, `chooseAudio` L325, Drive 가져오기)에서 이 값을 통째로 교체한다. 그래서 Drive에서 가져온 뒤 마이크로 녹음하거나 로컬 파일을 고르면 `driveSourceId`가 남지 않는다. 전사 완료(`TranscriptionResult` 수신, L400) 직후 `syncAfter(result.jobId)`, 전사문 가져오기 완료(L353) 직후 `syncAfter(imported.jobId)`, 증강 완료 직후에는 증강 요청의 `target`과 같은 `this.lastResult.jobId`(L426·L436)로 `syncAfter`를 부른다(증강 결과의 `jobId`가 아니다). +- `src/ui/app-template.ts` 추가 요소(선택자 고정): + - 온보딩 카드 `#drive-onboarding`(transcription-panel 위, 연결 안 됨 + 미해제일 때만 표시): 제목 "Google Drive 연결", 설명 "Drive에 있는 녹음을 가져오고 전사 결과를 내 Drive의 Galpi 폴더에 자동으로 올립니다. Galpi는 직접 만든 파일과 직접 고른 파일에만 접근합니다.", 버튼 `#drive-connect-button`(`data-action="connect-drive"`, primary) "Drive 연결", `#drive-dismiss-button`(`data-action="dismiss-drive-onboarding"`, ghost) "나중에", `#drive-onboarding-status`(`role="status"`). + - 상단바 `#drive-chip`(account-chip 옆, 연결 시 표시): `#drive-email`과 상태 문구. + - `#drive-import-button`(`data-action="import-from-drive"`, `#audio-selection` 옆): "Drive에서 가져오기". 활성 조건은 "Drive 연결됨 그리고 `!jobBusy && !recordingActive`"이며, 다른 작업 시작 버튼과 같이 `app-view.ts` `refreshActions`(L546–568)에서 계산한다. 그래서 전사·증강·엔진 준비·녹음 중에는 누를 수 없고 실행 중인 작업의 `this.job`과 busy 상태를 덮어쓰지 않는다. 진행 중에는 기존 `#job-panel`이 "Drive에서 녹음을 고르고 내려받는 중입니다" 단계를 보여 준다. 이를 위해 `app-view.ts`의 `#job-panel` 숨김 조건(L241 `setBusy`, L289–290 `renderJob`)과 `data-status` 설정(L234–237, L293–294)에 `jobKind === "driveImport"`를 `"transcription"`과 같이 포함해 패널을 보이고 `data-status="running"`으로 둔다. `#cancel-button` 표시 조건(L245–246)도 `"transcription"` 또는 `"driveImport"`로 넓힌다. `controller.ts` L411–417의 `cancel()`은 그대로 `this.job.jobId`(가져오기 작업 ID)로 `cancel_job`을 부르므로 Picker 대기와 다운로드를 모두 취소한다. + - 결과 영역 `#drive-sync-row`(`#results-panel`과 `#augment-panel` 산출물 목록 아래): 상태 문구 `#drive-sync-status`(`role="status"`), 파일별 상태, Drive 폴더 열기 링크 버튼 `#drive-open-folder-button`(기존 opener 경로로 `webViewLink` 열기 — capability allow 목록에 `https://drive.google.com/*` 추가), 충돌 시 `#drive-overwrite-button`(`data-action="overwrite-drive"`, danger) "Drive 버전 덮어쓰기". + - 설정 대화상자에 "Google Drive" 절 `#drive-settings`: 연결 계정, `#drive-settings-connect-button`, `#drive-disconnect-button`(danger) "연결 해제". + - 연결 대기 취소: 온보딩 카드에 `#drive-onboarding-cancel-button`, 설정 절에 `#drive-settings-cancel-button`(둘 다 `data-action="cancel-drive-operation"`, secondary) "취소"를 두고 연결 중일 때만 표시한다. id를 나누는 이유는 하위 뷰가 root의 `querySelector`로 첫 요소만 찾고(`dom.ts` L12), 설정 대화상자가 열리면 배경이 inert가 되어(`token-settings.ts` L56–60) 온보딩 쪽 버튼을 누를 수 없기 때문이다. +- 사용자 문구(오류 코드별): `DRIVE_NOT_CONNECTED` "먼저 Google Drive를 연결해 주세요.", `DRIVE_ACCOUNT_MISMATCH` "연결된 Drive 계정({email})과 다른 계정에서 파일을 골랐습니다.", `DRIVE_RECONNECT_REQUIRED`, `DRIVE_UNAVAILABLE` "Google Drive 또는 로그인 서버에 연결하지 못했습니다. 잠시 뒤 다시 시도해 주세요.", `DRIVE_UNSUPPORTED_FILE` "오디오나 영상 파일만 가져올 수 있습니다.", 충돌 "Drive의 파일이 다른 곳에서 바뀌어 덮어쓰지 않았습니다.". 상태는 색과 문구를 함께 쓴다(DESIGN.md). +- `src/styles.css`: 새 요소의 배치만 기존 토큰으로 추가한다. `DESIGN.md`에 Drive 온보딩·칩·동기화 상태 절을 추가한다. + +## TODOs + +- [ ] 1. 도메인 값 객체와 충돌 판정 + - What: `domain/drive.rs`(위 설계), `domain/mod.rs` 등록, `ArtifactKind`에 `Serialize`(snake_case) 추가. + - Must NOT: reqwest·Tauri·tokio 의존. + - References: `domain/gateway.rs` L11–28(비밀 값 `Debug` 가리기 패턴), `domain/artifact.rs` L19–25. + - Acceptance(RED→GREEN): `cargo test --manifest-path src-tauri/Cargo.toml --lib domain::drive` — `decide`의 다섯 경우(원격 없음, 같음, 로컬만 바뀜, 원격 바뀜, 원격 체크섬 없음)와 `overwrite`, `DriveFileId` 거부 사례(빈 값, `'`, `/`, 201자), `parse_picked_ids`(빈 문자열, 쉼표 목록), `MeetingKey` 형식과 124바이트 한도, `DriveTokens` `Debug`에 값이 없음. + - Commit: NO(한 PR, 마지막에 원자 커밋으로 나눔 — Commit Strategy 참고). +- [ ] 2. 로그인 게이트 bearer + - What: `AppAccessGate::new(.., clock)`, `bearer()`. + - References: `application/gateway.rs` L77–103(restore의 갱신·거부·오프라인 분기), L140–160. + - Acceptance: `application/tests/gateway.rs`에 — 만료 전이면 refresh 0회, 만료 60초 안이면 refresh 1회 + 회전 토큰 저장, Rejected면 저장소 비움 + `require()`가 `AUTH_REQUIRED`, Unavailable면 `GATEWAY_UNAVAILABLE`이고 세션 유지, 저장 토큰 없음 → `AUTH_REQUIRED`. 기존 gateway 테스트 그대로 통과. +- [ ] 3. Drive 저장소 + - What: `Secret` 열거형의 `DriveSession` 변형, `settings.rs` 네 match + 필드 + `is_empty`, `settings` 모듈 아래 `drive.rs`. + - Acceptance: `cargo test ... --lib settings::drive` — SettingsFile 백엔드에서 빈 설정에 저장한 연결·토큰이 새 store로 다시 읽힘, OS 비밀 저장소 백엔드에서는 파일에 토큰 문자열이 없음, `clear()` 뒤 다른 설정이 없으면 파일 삭제, `dismiss_onboarding` 유지, 이전 버전 JSON(Drive 필드 없음) 파싱. +- [ ] 4. Drive 인증 어댑터 + - What: `drive/mod.rs`, `authorize.rs`, `tokens.rs`, gateway URL 공유. + - References: `gateway/mod.rs` L131–145(인가 URL), L162–192(교환), L215–280(sign_in·refresh·revoke), `gateway/tests.rs` L76–110(가짜 서버 하네스). + - Acceptance: `cargo test ... --lib drive::tests` — 연결 URL의 필수 파라미터와 Picker 추가 파라미터, 콜백 `state` 불일치·`access_denied`·`code` 없음, 교환 요청의 bearer·JSON 본문, 교환 응답에서 토큰만 추출, 400 `invalid_grant` → Rejected, 401 → AuthRequired, 503·연결 거부·형식 오류 → Unavailable, 갱신 응답에 refresh token 없으면 기존 값 유지, revoke가 실패해도 패닉 없음, 오류 문구에 토큰·코드가 없음. +- [ ] 5. Drive REST 어댑터 + - What: `rest.rs`. + - Acceptance: 가짜 서버 테스트 — about 파싱, 루트 폴더 재사용/404 시 재생성/없으면 생성(`appProperties.galpiRoot`), 회의 폴더 재사용과 생성, multipart 본문(경계·메타데이터 JSON·내용), 갱신이 `PATCH`, `q` 이스케이프, 다운로드가 `.part`를 거쳐 최종 파일로 rename되고 최종 파일의 수정 시각이 가짜 서버가 준 `modifiedTime`과 같음, 다운로드 중 cancel 시 폴더 삭제와 `CANCELLED`, 403·404·429·5xx 매핑, 5 MiB 초과 업로드 거부. +- [ ] 6. DriveAccounts와 Application use case + - What: `application/drive.rs`, `use_cases.rs` 새 메서드·필드, `ports.rs` 포트 3개, `application/tests.rs` 가짜 포트, `application/tests/drive.rs`. + - Acceptance(GIVEN/WHEN/THEN): 로그인 전 `connect_drive`·`import_from_drive`·`sync_meeting_to_drive`가 `AUTH_REQUIRED`; 연결 성공 시 연결 정보 저장; 동시 연결 `DRIVE_BUSY`; 연결 대기 중 gateway 토큰이 만료 구간에 들어가도 교환에는 대기 뒤 갱신된 bearer가 쓰임(가짜 시계를 대기 중에 전진); 연결 대기를 `cancel_drive_operation`으로 취소하면 `CANCELLED`이고 다시 연결할 수 있음; 가져오기 계정 불일치 → `DRIVE_ACCOUNT_MISMATCH` + 새 토큰 미저장 + revoke 1회; 같은 계정이면 새 토큰 저장; Google 문서 MIME 거부; Picker 대기 중 `cancel_job`이 `CANCELLED`로 끝나고 슬롯을 놓음; 다운로드 중 취소도 슬롯을 놓음; `driveSourceId`가 기록과 다르면 전사 전 `DRIVE_SOURCE_MISMATCH`; 같은 로컬 오디오를 두 번 전사(두 번째는 출력 폴더가 `{stem} 2`)한 뒤 두 번째 동기화가 새 회의 폴더를 만들지 않고 같은 파일 ID를 `Updated` 또는 `Unchanged`로 돌려줌; 동기화 — 첫 실행 `Uploaded`×2, 같은 내용 재실행 `Unchanged`, 내용 변경 후 `Updated`(같은 파일 ID), 원격 변경 시 `Conflict` + 원격 파일 미갱신, `overwrite`면 `Updated`, 한 파일 실패는 `Failed`로 담고 나머지 진행, 증강 뒤 같은 전사 작업 ID로 동기화하면 회의록이 `Uploaded`, 갱신 Rejected → 연결 정보 삭제 + `DRIVE_RECONNECT_REQUIRED`, Drive 401 뒤 강제 갱신이 Rejected면 `DRIVE_RECONNECT_REQUIRED`이고 성공이면 원래 요청 재전송 없이 `DRIVE_UNAVAILABLE`, 루트 폴더가 지워졌으면 다시 만들고 저장된 ID 갱신; 연결 해제는 revoke 실패와 무관하게 비움; 동기화나 Picker 교환이 진행 중일 때 연결을 해제하면 끝난 뒤에도 저장소가 비어 있음(토큰·폴더 ID가 되살아나지 않음); gateway 갱신이 거부돼 `bearer()`가 세션을 지우면 이후 `require()`가 `AUTH_REQUIRED`. +- [ ] 7. IPC·조합 + - What: `tauri.rs` 명령 7개, `composition.rs`, `TranscriptionRequest.drive_source_id`, `capabilities/default.json` opener allow에 `https://drive.google.com/*`, 시작 시 `drive-imports` 정리. + - Acceptance: `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`, `bun run architecture:check` 통과(`package.json` L26). +- [ ] 8. 프런트엔드 + - What: 위 프런트엔드 설계 전부. + - Acceptance: `drive-machine.test.ts`(모든 이벤트 전이), `access-machine.test.ts`(`signedIn`에서 `sessionExpired`를 받으면 `signedOut`), `tauri-backend.test.ts`(새 스키마의 성공·거부 사례, 토큰 필드가 있어도 상태에 남지 않음), `drive.dom.test.ts`(온보딩 표시·해제·연결 성공 후 숨김, 온보딩에서 연결 중 `#drive-onboarding-cancel-button` 표시와 취소 호출, 설정에서 연결 중 `#drive-settings-cancel-button` 표시와 `cancelDriveOperation` 1회 호출, 가져오기 버튼 활성 조건, 충돌 시 덮어쓰기 버튼, 재연결 필요 시 온보딩 재표시, Drive 명령이 `AUTH_REQUIRED`로 실패하면 `#access-sign-in-button`이 보이는 로그인 화면으로 돌아감), `controller.test.ts`(`styles.css`를 주입한 상태(L64–66 방식)에서 검사한다: 전사 요약이 없는 새 세션에서 `importFromDrive` 응답을 보류한 동안 `#cancel-button.closest("[hidden]") === null`, `#job-panel`의 `data-status`가 `"running"`, `#job-title`이 "Drive에서 녹음을 가져오고 있습니다"이며, 클릭하면 `importFromDrive`에 넘긴 jobId로 `backend.cancel`이 1회 호출됨; 가져오기가 성공하면 `#job-panel`이 hidden이고 화면 어디에도 보이는 "전사 완료" 문구가 없으며 `#audio-path`에 가져온 파일 이름이 보임(이전 전사를 마친 세션에서도 같음); `DRIVE_UNSUPPORTED_FILE`로 실패하면 `data-status === "failed"`이고 `#error-message`에 이유가 보임; 가져오기가 끝나면 busy가 풀림; `transcribe` 응답을 보류한 동안 `#drive-import-button.disabled === true`이고 `#cancel-button`은 여전히 전사 작업 ID로 `backend.cancel`을 부름; 전사·전사문 가져오기·증강 완료 후 연결 상태에서만 `syncMeetingToDrive` 호출; 증강 뒤 호출의 첫 인자가 전사 작업 ID; Drive 원본이면 요청에 `driveSourceId`; Drive에서 가져온 뒤 로컬 파일을 고르거나 마이크로 녹음해 전사하면 요청에 `driveSourceId`가 없음), `styles.test.ts` 통과. `bun run check`(tsc)가 통과해야 하며 `DriveImport`를 `TranscriptionResult` 자리에 넘기는 호출이 없다. +- [ ] 9. 문서 + - What: `README.md`/`README.en.md`(Drive 연결·가져오기·자동 업로드·충돌·연결 해제·저장 위치·권한 범위·개인정보처리방침 링크), `INSTALL.md`(첫 실행 단계에 Drive 연결은 선택), `DESIGN.md`(Drive 요소 상태), `docs/ARCHITECTURE.md`(포트 18, 명령 35, Drive 어댑터). + - Acceptance: `grep`으로 README 두 벌에 `drive.file`과 `Galpi` 폴더 설명이 모두 있음. + +## 게이트 + +- G1: 도메인 규칙 고정 | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib domain::drive | EXPECT: 실패 0, 충돌 판정 다섯 경우 포함 +- G2: 로그인 게이트 bearer | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib application::tests::gateway | EXPECT: 실패 0, bearer 다섯 경우 포함 +- G3: Drive 저장소 영속성 | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib settings::drive | EXPECT: 실패 0 +- G4: Drive 어댑터 계약 | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib adapters::outbound::drive | EXPECT: 실패 0 +- G5: Drive use case | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --lib application::tests::drive | EXPECT: 실패 0 +- G6: Rust 전체 | CHECK: cargo test --manifest-path src-tauri/Cargo.toml --all-targets | EXPECT: 실패 0 +- G7: Rust 린트 | CHECK: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings | EXPECT: 종료 코드 0 +- G8: Rust 형식 | CHECK: cargo fmt --manifest-path src-tauri/Cargo.toml --check | EXPECT: 종료 코드 0 +- G9: 프런트 정적 검사와 아키텍처 펜스 | CHECK: bun run check | EXPECT: 종료 코드 0 +- G10: 프런트 테스트 | CHECK: bun test | EXPECT: 실패 0 +- G11: 소스에 클라이언트 보안 비밀번호 없음 | CHECK: git grep -n GOCSPX | EXPECT: 출력 없음(종료 코드 1) +- G12: 실제 스모크(배포된 auth-gateway#10 필요) | CHECK: bun run dev | EXPECT: 증거 파일 `.issueops/evidence/task-12-drive-smoke.md`에 연결→폴더 생성, Picker 가져오기→전사→업로드, 재처리 Unchanged/Updated, Drive 쪽 수정 후 Conflict, 연결 해제 결과와 시각이 기록됨. gateway가 아직 배포되지 않았으면 그 사실과 gateway 이슈 상태를 근거로 보류를 기록한다. + +## 실제 QA 시나리오 + +- 정상: 로그인 → 온보딩 `Drive 연결` → 브라우저 동의 → 칩에 이메일 → Drive 웹에서 `Galpi` 폴더 확인 → `Drive에서 가져오기` → Picker에서 m4a 선택 → 다운로드 진행 표시 → 전사 완료 → `#drive-sync-status`가 업로드 완료 → Drive에 회의 폴더와 srt·txt → AI 증강 → 회의록 업로드 → 같은 회의 증강 재실행 → 회의록 `Updated`, 파일 ID 동일. +- 실패: Picker를 다른 계정으로 선택 → 계정 불일치 문구, 토큰 미교체; 다운로드 중 취소 → `drive-imports`에 남은 폴더 없음; Drive 웹에서 txt 새 버전 업로드 → 재처리 시 충돌 문구와 덮어쓰기 버튼 → 덮어쓰기 후 `Updated`; 네트워크 차단 상태 동기화 → `DRIVE_UNAVAILABLE` 문구, 로컬 결과 정상; Google 계정 권한 페이지에서 Galpi 접근 해제 → 다음 동기화에서 재연결 안내와 온보딩 재표시. +- 화면 QA: Vite 개발 서버 + 가짜 `__TAURI_INTERNALS__`로 온보딩·칩·동기화 행·충돌 버튼을 열어 `elementsFromPoint`로 가려지지 않음을 확인. + +## 의존성과 로컬 설정 + +- 워크트리에서 `bun install`. Rust 크레이트는 추가하지 않는다(reqwest·serde_json·sha2·base64·getrandom·tokio fs 이미 사용 중). +- 로컬 설정: 없음. 디버그 스모크에서 로컬 gateway를 쓸 때만 셸 환경 변수 `GALPI_AUTH_GATEWAY_URL`. + +## Commit Strategy + +Conventional Commit(`feat(drive): …`, `docs(readme): …`). 구현이 끝난 뒤 `atomic-commit-push`로 도메인·application, 어댑터, IPC·프런트, 문서 단위의 원자 커밋으로 나눈다. 각 커밋은 빌드 가능해야 한다. + +## Success Criteria + +이슈 #12의 완료 기준 전부와 G1–G11 통과. G12는 gateway 배포 여부에 따라 실행 또는 근거 있는 보류. diff --git a/.issueops/issues/12/verification-report.md b/.issueops/issues/12/verification-report.md new file mode 100644 index 0000000..0dadb1a --- /dev/null +++ b/.issueops/issues/12/verification-report.md @@ -0,0 +1,127 @@ +# galpi#12 구현 검증 보고 + +- Lifecycle: `io-21fd3b1e1f06`, generation 2(최초 구현), generation 3(Windows CI 실패 수정, 아래 절), branch `12-google-drive-sync`, base `4c24a2479fc4bf294ffeecc81fd06ea7438894fe` +- 계획: `.issueops/issues/12/plan.md` (sha256 `50c8de3550ebb0db6083592620d5c73c88224cbb8f6e01d1469fb421a07fcacb`) +- 게이트 원장: `.issueops/issues/12/gates.md` (G1~G13 충족) + +## 게이트 결과 + +| 게이트 | 명령 | 결과 | +|---|---|---| +| G1 도메인 | `cargo test --lib domain::drive` | 12개 통과(충돌 판정 5경우, 파일 ID 거부, 회의 키 한도, FNV-1a 벡터, 토큰 Debug 가림) | +| G2 gateway bearer | `cargo test --lib application::tests::gateway` | 16개 통과(새 bearer 5경우 포함) | +| G3 Drive 저장소 | `cargo test --lib settings::drive` | 5개 통과 | +| G4 Drive 어댑터 | `cargo test --lib adapters::outbound::drive` | 23개 통과 | +| G5 Drive use case | `cargo test --lib application::tests::drive` | 25개 통과 | +| G6 Rust 전체 | `cargo test --all-targets` | 295 통과, 1 무시(기존) | +| G7 clippy | `cargo clippy --all-targets -- -D warnings` | 경고 없음 | +| G8 fmt | `cargo fmt --check` | 통과 | +| G9 프런트 정적 | `bun run check` | 통과 | +| G10 프런트 테스트 | `bun test` | 233 통과 | +| G11 비밀번호 없음 | `git grep --untracked -E` (Google 클라이언트 비밀번호 모양: 접두어-20자 이상) | 일치 없음. 계획의 접두어 단독 검색은 추적 파일만 보고 계획 문서의 설명 문구에도 걸려, 미추적 파일까지 보고 실제 값 모양만 찾도록 바꿨다(가짜 값 탐침 파일로 탐지 확인 후 삭제) | +| G12 실제 스모크 | `.issueops/evidence/task-12-drive-smoke.md` | 실제 Google·auth-gateway·Drive로 연결→Picker→업로드→Unchanged→Updated→Conflict→덮어쓰기→회의록→해제 확인 | +| G13 이름 정리 OS 독립 | `cargo test --lib downloaded_names_are_sanitized_and_keep_the_extension` | 1개 통과(Windows 경로 사례 4개 추가, generation 3) | + +## 계획과 다르게 구현한 점 + +1. `Artifacts.source_transcript` 추가: 전사문 가져오기의 회의 키(정경화한 원본 경로)를 application이 파일 시스템 없이 얻도록, 가져오기 어댑터가 이미 정경화한 입력 경로를 산출물에 실었다. +2. 회의 키 해시를 SHA-256 대신 FNV-1a 64로 했다: `docs/ARCHITECTURE.md` §1은 Rust domain 의존을 std·serde·thiserror·uuid로 제한한다. 업로드 내용의 SHA-256(`galpiSha256`, Drive `sha256Checksum` 비교)은 어댑터(`rest.rs`)가 계산한다. +3. `DriveFilesPort`에 `read_local`을 두었다: 충돌 판정에 필요한 로컬 체크섬을 application이 파일을 읽지 않고 얻기 위해서다. 5 MiB 초과 거부도 여기서 한다. +4. `download`의 저장 위치는 어댑터가 정한다(`AppPaths.cache/drive-imports//`): application은 `AppPaths`를 모른다. +5. multipart 경계 문자열은 `random_urlsafe` 대신 UUID v4를 쓴다(실패 경로가 없다). +6. 프런트엔드 `openDriveLink(url)`를 `BackendPort`에 추가했다: 동기화 행의 `Drive 폴더 열기`가 opener를 쓰려면 필요하다. `https://drive.google.com/`이 아닌 주소는 거부한다. +7. `AppController.begin()`에서 `setBusy(kind)`를 `renderJob()`보다 먼저 부른다: Drive 가져오기 시작 시 진행 카드 제목이 이전 작업 종류로 그려지는 결함을 DOM 테스트가 잡았다. 6차 계획 리뷰의 비차단 메모 2(다음 전사 시작 직후 제목이 잠시 틀림)도 같은 원인이라 함께 해소된다. +8. Drive 아이콘은 Phosphor의 Drive 로고 대신 일반 `cloud` 아이콘을 쓴다: ChatGPT 패널과 같이 브랜드 자산을 쓰지 않는다. +9. 계획이 `controller.test.ts`에 두려던 가져오기 흐름 검증은 `src/ui/drive.dom.test.ts`에 모았다(스타일 주입, 같은 단언). +10. gateway가 Drive 토큰 요청의 bearer를 401로 거절하면 호스트도 세션을 닫는다(`AppAccessGate::expire`). 창은 `AUTH_REQUIRED`를 받으면 로그인 화면으로 돌아가므로, 호스트가 `SignedIn`으로 남아 기능 명령을 계속 받는 불일치를 없앴다. +11. `bearer()`의 `GATEWAY_UNAVAILABLE` 문구는 Drive를 언급하지 않는 범용 문구("로그인 서버에 연결하지 못했습니다. 잠시 뒤 다시 시도해 주세요.")로 했다. + +## ai-slop-clean + +읽기 전용 검토 에이전트가 diff에서 찾은 후보를 분류해 반영했다. + +- 결함: 새 회의 동기화 중 이전 회의의 파일 줄과 동작하지 않는 `Drive 폴더 열기`가 남던 문제(`drive-view.ts`), 상태를 읽기 전 설정 시트에 동작하지 않는 `Drive 연결`이 보이던 문제(템플릿 `hidden`), gateway 401 뒤 호스트 세션 불일치(10번). 앞의 두 건은 DOM 회귀 테스트 2개로 고정했다. +- 중복: 동의→bearer→교환 흐름을 `DriveAccounts::consent`로, 401 복구 분기 5벌을 `recovered`/`after_refused_token`으로, `auth_required`를 gateway 모듈 하나로, `STATE_ERROR` 생성을 `state_error()`로 묶었다. `sha256_hex`는 크레이트의 기존 `format!("{:x}", …)` 관용구를 쓴다. +- 쓰지 않는 코드: `DriveFileId`의 `Hash`, `tokens_from`의 도달할 수 없는 `ok_or`, 추론되는 `LocalUpload` 표기, 내보낼 필요 없는 `DRIVE_CONFLICT_MESSAGE` export, 이름만 바꾸던 `DriveImported` 타입, 동기화 루프의 도달할 수 없는 오류 코드 목록(`DRIVE_UNAVAILABLE`만 남기고 테스트 추가). +- 약한 산출물·근거 없는 주장: 모듈 문서의 "모든 저장소 쓰기" 범위, `rest.rs`의 "Drive id 로컬 기록 불필요" 문장, refresh token을 "보장"한다는 주석, 테스트 구획 주석, 동작과 다른 테스트 이름을 고쳤다. REST 어댑터의 `DRIVE_UNAVAILABLE` 문구에서 로그인 서버 언급을 뺐다. +- 함께 고친 결함 가능성: 다운로드 첫 응답에도 60초 제한(`CHUNK_TIMEOUT`)을 걸었다. +- 의도적으로 유지: `DriveImport.jobId`와 파일별 `webViewLink`(계획의 IPC 계약), 비슷한 설정을 가진 두 취소 테스트(Picker 대기와 다운로드라는 서로 다른 지점), 테스트 모듈마다 있는 `lock`/`failure` 도우미(기존 관례). + +정리 전후 측정(같은 스크립트, 변경·새 파일의 `.rs`/`.ts`/`.css` 추가 줄, 셸 휴리스틱): + +| 지표 | 정리 전 | 정리 후 | +|---|---|---| +| SNR(신호/전체 비어 있지 않은 줄) | 0.920 (5,638 / 6,131) | 0.916 (5,698 / 6,219) | +| 같은 파일 7줄 이상 동일 블록 | 4 | 2(두 취소 테스트, 유지) | +| 테스트 제외 250줄 초과 새 파일 | 4 | 4(`rest.rs` 749, `application/drive.rs` 533, `domain/drive.rs` 440, `settings/drive.rs` 278; 셋은 인라인 테스트 포함) | + +SNR이 0.004 낮아진 것은 회귀 테스트 2개·application 테스트 2개와 설명 주석이 늘었기 때문이다. 기준선(0.60)과 목표(0.75)를 모두 넘는다. + +## 비차단 메모 반영 + +- 메모 1: 가져오기 성공 시 `view.setAudio(path)`를 `renderDriveImported(name)` 앞에서 부른다(`controller.ts` DriveHooks.onImported). +- 메모 2: 위 7번으로 해소. `setBusy(null)`에서 `jobKind`를 비우지 않았다(취소 제목 보존). + +## UI 판단 + +- 디자인 시스템: 새 스타일은 기존 SEED 토큰만 쓴다(`src/styles.test.ts` 통과, 새 hex·px·커스텀 속성 없음). 버튼은 `actionButtonClass` recipe, 상태는 색과 문구를 함께 쓴다. +- 접근성: 상태 문구는 모두 `role="status"` live region, 설정 시트가 배경을 inert로 만들기 때문에 취소 버튼 id를 카드·설정으로 나눴다. 가져오기 버튼은 다른 시작 동작과 같이 작업·녹음 중 비활성. +- 실제 화면: Vite 개발 서버 + 가짜 `__TAURI_INTERNALS__`로 온보딩 카드, 연결 대기(취소만 보임), 연결 후 칩·가져오기 버튼, 가져오기 진행 카드(`작업 취소`가 `elementsFromPoint` 최상단), 가져오기 완료(카드 숨김, 파일 이름 표시, "전사 준비됨"), 동기화 충돌 행(덮어쓰기 버튼), 설정 Drive 절(연결 해제 최상단)을 1440×1000에서 확인했다. 설정 도움말 문장 글꼴이 다른 도움말보다 큰 문제를 발견해 `#drive-settings-help`로 맞췄다. +- 반응형·모션: 새 요소는 기존 그리드·flex-wrap 규칙을 따르며 애니메이션을 추가하지 않았다. + +## side effect + +- 새 IPC 명령 7개, `settings.json`의 Drive 필드 6개(`serde(default)`), Windows Credential Manager `com.m16khb.galpi:drive-session`. +- 로컬: `AppPaths.cache/drive-imports//`(실패·취소 시 즉시 삭제, 앱 시작 시 전체 삭제). +- 원격(사용자 Drive): `Galpi` 폴더, 회의별 폴더, 결과 파일 최대 3개. 원본은 읽기만 한다. +- opener 허용 목록에 `https://drive.google.com/*`. +- 스모크가 만든 Drive 파일·폴더는 휴지통으로 옮겼고, Drive·gateway 토큰은 폐기했다. + +## 성능 + +- 전사 핫패스(워커, JSONL 이벤트, CPAL 녹음)는 바꾸지 않았다. Drive 업로드는 전사 명령이 끝난 뒤 별도 IPC로 실행된다. +- 다운로드는 `Response::chunk()`를 `.part` 파일에 바로 쓴다. 첫 응답과 각 청크 사이 대기는 60초로 제한한다. +- 동기화 한 번의 Drive 왕복: 루트 확인 1~2회, 회의 폴더 찾기 1회(없으면 생성 1회), 파일마다 찾기 1회 + 생성·갱신 1회(변경 없으면 0회). 실제 스모크에서 결과 2~3개 동기화는 1~4초 걸렸다(증거 파일 시각). +- 토큰 갱신은 Drive 만료 5분 전, gateway 만료 60초 전부터만 일어난다. +- 측정하지 않은 것: 50MB 이상 다운로드 중 메모리 증가(아래 남은 확인). + +## 검증 단계 피드백 반영 + +구현 리뷰(pass, 비차단 4건)와 화면 QA(aside-web-qa, "Ready with minor fixes", 기능 결함 없음, F1~F20 Pass)의 지적 중 사용자에게 보이는 것을 고치고 다시 검증했다. + +| 출처 | 지적 | 수정 | 회귀 확인 | +|---|---|---|---| +| 리뷰 1 | Picker 대기 중 다른 계정으로 다시 연결하면 A 토큰이 B 연결에 저장됨 | `pick()`이 저장 잠금 안에서 현재 연결의 `permission_id`까지 대조, 다르면 새 토큰 철회 후 `DRIVE_NOT_CONNECTED` | `reconnecting_another_account_during_a_picker_consent_keeps_its_tokens` | +| 리뷰 2 | 사용자가 직접 연결 해제한 뒤의 `DRIVE_NOT_CONNECTED`가 온보딩 카드를 오류로 다시 띄움 | 창이 Drive 연결 상태일 때만 `reconnectRequired` | `a sync ended by the user's own disconnect does not bring the card back` | +| 리뷰 3 | 브라우저에서 동의 거부·Picker 닫기가 실패 카드로 표시됨 | `CANCELLED`는 `cancelJob`으로 취소 카드 | `an import declined in the browser cancels the card instead of failing it` | +| 리뷰 4 | `.drive-status:empty { display: none }`이 live region을 접근성 트리에서 뺌 | 빈 줄은 여백만 0 | Chromium에서 빈 `#drive-onboarding-status`의 계산된 `display: block` 확인 | +| VQA-1 | 긴 Drive 이메일이 1024px 상단 바를 넘겨 설정 버튼이 화면 밖으로 밀림 | Drive 칩 최대 220px, 말줄임, 전체 주소는 `title` | Chromium 1024px, 73자 이메일: `scrollWidth` 1024, 설정 버튼 오른쪽 984 | +| VQA-3 | `Drive 연결` 등이 숨으면 포커스가 body로 떨어짐 | 숨은 Drive 버튼의 포커스를 대신 나타난 보이고 활성인 컨트롤로 이동(`FOCUS_HANDOFF`), 작업 중 시작 동작이 비활성이면 설정 버튼으로, 가져오기 버튼 표시를 Drive 렌더보다 먼저 갱신 | DOM 테스트 2개(연결→취소→가져오기 버튼, 전사 중 `나중에`→설정 버튼) + Chromium 키보드 Enter로 연결 순서 확인 | + +수정분 delta 리뷰: pass. 지적 1건(작업 중 비활성 후보로 포커스가 떨어짐, 비차단)을 위 VQA-3 행의 설정 버튼 대체로 반영했다. + +고치지 않은 낮은 우선순위 QA 지적(후속 후보): VQA-2 재연결 요구 때 동기화 행에 연결 버튼이 없음, VQA-4 1024px 온보딩 제목 줄바꿈, VQA-5 계정 칩과 Drive 칩 모양이 같음. 화면 QA의 Not Run: 실행 중 `prefers-reduced-motion` 전환(새 애니메이션 없음을 소스와 계산된 스타일로만 확인), 새로고침 뒤 유지, 네이티브 동작. + +## 남은 확인 + +- 50MB 이상 다운로드 중 메모리 측정: 실행하지 않음(증거 파일 참고). +- 실제 앱 창(`bun run dev`)에서의 전 과정: 이 컴퓨터에 엔진·모델·HF 토큰이 없어 실행하지 않음. +- auth-gateway#10 PR #11은 아직 열려 있으나 엔드포인트는 배포되어 응답한다. + +## 게시 + +- Draft PR: https://github.com/m16khb-org/galpi/pull/13 (base `main`, head `12-google-drive-sync`, 라벨 `enhancement`, 담당자 m16khb, Closes #12). `remote verify-artifact`로 확인했다. +- 커밋 4개: Rust 호스트(`feat(drive)`), 화면(`feat(drive)`), 제품 문서(`docs(drive)`), IssueOps 기록(`docs(issueops)`), 그리고 이 게시 기록 커밋. +- 머지, 배포, 워크트리 정리는 이 사이클 범위 밖이다. + +## generation 3: Windows CI 실패 수정 + +- 원인: PR #13 CI run 38081588654의 `rust (windows-latest)`에서 `downloaded_names_are_sanitized_and_keep_the_extension`이 실패했다. `local_name`이 Drive 파일 이름을 `Path`로 해석했기 때문에, Windows는 `a:b?.WAV`의 `a:`를 드라이브 접두어로 보고 버려 `b.WAV`를 냈다. macOS는 `\`를 일반 문자로 보아 `C:\x\y.m4a`가 `C--x-y.m4a`가 되는 등 두 OS의 결과가 달랐다. +- 수정: `local_name`(`drive/rest.rs`)이 OS 경로 해석 없이 문자열을 직접 나눈다. `/`와 `\`를 모든 OS에서 구분자로 보고, 빈 조각과 `.`을 건너뛴 마지막 조각을 쓰며 `..`이면 `meeting`이 된다. 확장자 분리는 `Path::file_stem`과 같은 규칙(점으로 시작하는 이름은 확장자 없음)이다. 이름 정리는 `paths.rs`의 `meeting_stem`에서 문자열용 `sanitized_stem`을 분리해 재사용한다. `meeting_stem`의 동작은 그대로다. +- RED: 테스트에 Windows 경로 사례 4개(`C:\x\y.m4a`, `..\..\boot.ini`, `C:`, `c:.wav`)를 더하자 macOS에서 `left: "C--x-y.m4a", right: "y.m4a"`로 실패했다. +- GREEN: `cargo test --lib adapters::outbound` 167 통과(1 무시, 기존), `cargo fmt --check` 통과. G13으로 원장에 기록했다. Windows 결과는 push 뒤 PR #13 CI로 확인한다. +- side effect: 다운로드 캐시 파일 이름이 바뀌고, 그 경로가 전사 입력이 되므로 거기서 나오는 로컬 회의 폴더·결과 파일 stem(`meeting_stem`)과 새로 만드는 Drive 회의 폴더 이름(`DriveMeeting::new`)도 바뀐다. 해당하는 이름은 macOS·Linux에서 `\`가 든 Drive 이름(`1팀\주간 회의.m4a`: `1팀-주간 회의` → `주간 회의`), Windows에서 드라이브 접두어가 든 이름(`a:b?.WAV`: `b` → `a-b`)이다. 결과 파일 접미사, 회의 식별 키(`drive:`), 원격·설정·IPC 계약은 바뀌지 않는다. +- ai-slop-clean: 여섯 분류(쓰지 않는 코드, 중복, 불필요한 추상화, 경계 위반, 약한 산출물, 근거 없는 주장)를 diff에서 검사했고 제거할 항목은 없었다. `sanitized_stem`은 `meeting_stem`과 `local_name`이 함께 쓰는 정리 규칙을 한 곳에 두기 위한 분리이며, `meeting_stem`의 UTF-8이 아닌 이름 처리(`meeting`)도 그대로다. 측정(`git diff -U0 -- src-tauri`의 추가 줄): 비어 있지 않은 줄 38, 주석 5, SNR 0.868. `git diff --check` 통과. +- 성능: 다운로드 한 번에 이름 문자열 하나를 나누는 비용뿐이고 전사 핫패스는 바꾸지 않았다. 측정하지 않았다. +- 화면 QA: Not Run. generation 3 diff에는 프런트엔드(`src/`)나 스타일 변경이 없고 Rust 어댑터 두 파일과 IssueOps 기록만 바뀌었다. generation 2의 aside-web-qa 결과(위 절)가 그대로 유효하다. +- 운영 문서: `.issueops/cautions/2026-10-11-drive-path-windows.md`를 추가하고 `cautions/overview.md`에 연결했다. diff --git a/.issueops/testing/overview.md b/.issueops/testing/overview.md index 3c3c475..be85905 100644 --- a/.issueops/testing/overview.md +++ b/.issueops/testing/overview.md @@ -45,6 +45,20 @@ Manual Windows gates (not automated): - Save the Hugging Face token and assistant API key, restart the app, and confirm they persist (Windows Credential Manager entries `com.m16khb.galpi:hugging-face-token` / `:assistant-api-key`). +- Connect Google Drive, restart the app, and confirm the connection persists + (Windows Credential Manager entry `com.m16khb.galpi:drive-session`). + +Google Drive checks: + +- Adapter contracts (consent URL, gateway-brokered exchange, Drive v3 REST, + streamed download) run against `browser_sign_in/fake_server.rs` in + `cargo test --lib adapters::outbound::drive`; use cases run against the + in-memory Drive in `application/tests/drive.rs`. Neither touches the network. +- A live check needs auth-gateway `POST /drive/native/token` deployed (without a + bearer it answers 401 `missing bearer token`; an undeployed route answers 404) + and drives the real consent and Picker in the default browser. Record it under + `.issueops/evidence/` like `task-12-drive-smoke.md`; never commit the harness, + tokens, or the client secret. Benchmarks (no network, no ML stack; not part of CI or `check:all`): diff --git a/AGENTS.md b/AGENTS.md index e26131b..0e64505 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,11 +47,14 @@ Generated trees (`node_modules`, `dist`, `src-tauri/target`, | Frontend backend port | `src/domain/backend.ts` | Port contract owned by the inner layer; `TauriBackend` implements it | | Tauri IPC client | `src/adapters/tauri-backend.ts` | `invoke`/`listen` plus Zod boundary parsing | | Native composition | `src-tauri/src/composition.rs` | Only concrete port wiring and Tauri registration | -| IPC command surface | `src-tauri/src/adapters/inbound/tauri.rs` | 28 frontend commands and event bridges (`job-event`, `recording-event`, `chatgpt-event`) | +| IPC command surface | `src-tauri/src/adapters/inbound/tauri.rs` | 35 frontend commands and event bridges (`job-event`, `recording-event`, `chatgpt-event`) | | Google sign-in gate (use cases) | `src-tauri/src/application/gateway.rs` | `AppAccessGate`: restore/renew, sign-in, sign-out, `AUTH_REQUIRED` on feature commands behind `GatewayAuthPort`/`GatewaySessionStore` | | Google sign-in gate (adapters) | `src-tauri/src/adapters/outbound/gateway/`, `settings/gateway.rs` | auth-gateway `/auth/native/*` client; session in the secret store | | Google sign-in gate (UI) | `src/ui/access-controller.ts`, `src/application/access-machine.ts` | Login screen in front of the inert shell, account chip, sign-out | | Shared browser sign-in | `src-tauri/src/adapters/outbound/browser_sign_in/` | System browser, loopback callback, PKCE; callers pass their error codes | +| Google Drive (use cases) | `src-tauri/src/application/drive.rs`, `src-tauri/src/domain/drive.rs` | `DriveAccounts`: connect, Picker import, sync with stateless conflict check behind `DriveAuthPort`/`DriveFilesPort`/`DriveSessionStore`; gateway bearer from `AppAccessGate::bearer()` | +| Google Drive (adapters) | `src-tauri/src/adapters/outbound/drive/`, `settings/drive.rs` | Consent/Picker URL, gateway-brokered token exchange, Drive v3 REST; tokens in the secret store | +| Google Drive (UI) | `src/ui/drive-controller.ts`, `src/ui/drive-view.ts`, `src/application/drive-machine.ts` | Onboarding card, chip, import as a job, sync row, settings section | | ChatGPT sign-in (use cases) | `src-tauri/src/application/chatgpt.rs` | Session, token refresh, model list, sign-out behind `ChatGptAuthPort`/`ChatGptStore` | | ChatGPT sign-in (adapters) | `src-tauri/src/adapters/outbound/chatgpt/` | OAuth authorize/callback, ID-token, models | | ChatGPT sign-in (UI) | `src/ui/chatgpt-controller.ts` | Sign-in/out, model choice, `chatgpt-event` handling | @@ -75,7 +78,7 @@ call sites, not semantic workspace references. |--------|------|----------|------|------| | `AppController` | class | `src/ui/controller.ts` | 1 construction | Frontend workflow coordinator | | `TauriBackend` | class | `src/adapters/tauri-backend.ts` | 1 construction | IPC and runtime-validation boundary | -| `Application` | struct | `src-tauri/src/application/use_cases.rs` | 28 command paths | Backend use-case facade | +| `Application` | struct | `src-tauri/src/application/use_cases.rs` | 35 command paths | Backend use-case facade | | `run` | function | `src-tauri/src/composition.rs` | 1 entry call | Native composition root | | `run_process` | async function | `src-tauri/src/adapters/outbound/process.rs` | 3 production calls | Worker/process supervisor | | `NativeRecorder` | struct | `src-tauri/src/adapters/outbound/recording/mod.rs` | 1 production wiring | Recording port implementation | diff --git a/DESIGN.md b/DESIGN.md index 5183c88..af19d07 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -227,6 +227,15 @@ nearest step, ties going up; structural sizes (248px rail, 560px sheet, 480px lo - **Usage limit**: when a refinement fails with `CHATGPT_USAGE_LIMIT_EXCEEDED`, `#chatgpt-limit-hint` appears in the augment panel with `사용량 관리` as its primary button (opens `https://chatgpt.com/settings/usage`) and states that the app does not retry; the next run clears it. - **Accessibility**: the message line is a `role="status"` live region (`#chatgpt-limit-hint` is `role="alert"`); every state carries text beside its color; buttons keep the 40px minimum height and Korean copy uses `word-break: keep-all`. Autosave of the mode and model follows the Settings Autosave rules; the window never holds a token, only the account state and e-mail. +### Google Drive + +- **Structure**: four surfaces share one state. The onboarding card `#drive-onboarding` sits between the setup panel and the transcription panel as a regular panel (`선택 / Google Drive`, heading `Google Drive 연결`, one sentence saying Galpi reaches only the files it created or the user picked), with `Drive 연결` (primary), `나중에` (ghost), and a cancel button that replaces both while the browser consent waits. Connected, the top bar shows a Drive chip (the account e-mail over `Drive 연결됨`) and the transcription panel shows `Drive에서 가져오기` as a second file picker under `오디오 파일 선택`. The settings dialog has a `Google Drive` section with the account line and `Drive 연결` / `취소` / `연결 해제`. The results panel ends with the sync row `#drive-sync-row`: a status line, one line per uploaded file, `Drive 폴더 열기`, and `Drive 버전 덮어쓰기` only on a conflict. +- **States**: the card shows only while disconnected and not dismissed; a lost connection (`DRIVE_RECONNECT_REQUIRED`, or `DRIVE_NOT_CONNECTED` while the window still shows Drive connected) brings it back with the reason. A failure that follows the user's own `연결 해제` does not. Status lines always state the state in words beside the color — success (`<이메일> 계정의 Drive에 연결했습니다.`, `Drive의 Galpi 폴더에 올렸습니다.`, `Drive에 이미 최신 결과가 있습니다.`), warning (the browser wait, `Drive에 결과를 올리는 중입니다.`, `Drive의 파일이 다른 곳에서 바뀌어 덮어쓰지 않았습니다.`), and critical (the host's Korean message). File lines read `<종류> · 올림/갱신함/변경 없음/Drive에서 바뀌어 건너뜀/올리지 못함: <이유>`; conflict and failure lines take the warning and critical text colors. +- **Import as a job**: `Drive에서 가져오기` follows the same rule as every other start action — disabled while a job or a recording runs. While the Picker waits and the file downloads, the transcription panel's progress card shows `Drive에서 녹음을 가져오고 있습니다` with `작업 취소`, but no percentage or phase list, because the download reports no progress worth a number. Success folds the card away (no `전사 완료` summary) and the audio picker names the Drive file; failure and cancellation keep the card with `Drive에서 녹음을 가져오지 못했습니다` / `Drive 가져오기를 취소했습니다`. Declining consent or closing the Picker in the browser (`CANCELLED`) is a cancellation, not a failure. +- **Behavior**: nothing retries on its own. A failed upload never fails the local result; overwriting needs the explicit `Drive 버전 덮어쓰기`. Disconnecting keeps the files on Drive. The window never holds a Drive token, only the connection state and e-mail. +- **Layout**: the Drive chip shrinks before anything else in the top bar: at most 220px wide, the e-mail truncates with an ellipsis and the full address stays in the chip's `title`, so a long address never pushes the settings button off a 1024px window. +- **Accessibility**: every status line is a `role="status"` live region and stays rendered while empty (only its margin collapses), so its first message is announced; buttons use the SEED `action-button` recipe at the 40px minimum height; the settings cancel button has its own id because the sheet makes the background inert, so the card's cancel cannot be reached from it. When a Drive button hides itself, focus moves to the visible, enabled control that took its place (connect → cancel → connect or `Drive에서 가져오기`, `나중에` → `오디오 파일 선택`, `연결 해제` → `Drive 연결`); while a job or a recording disables the start actions, focus goes to the settings button instead of dropping to the page. + ### Settings Autosave - **Behavior**: text fields persist when the user commits the edit (blur or Enter); selects and row removals persist immediately. There is no global save button. diff --git a/INSTALL.md b/INSTALL.md index a770fe0..6d5f425 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -173,6 +173,9 @@ steps themselves (details in the README sections of the same names): `설정`. 3. **Prepare the local engine**: `설정` → `로컬 엔진 준비`. This downloads an app-managed Python environment and several GB of models. +4. **Connect Google Drive** (optional): `Drive 연결` on the card shown after + sign-in, or `설정` → **Google Drive**. The system browser asks for the + `drive.file` permission only. Skipping it changes nothing else. ## Updating diff --git a/README.en.md b/README.en.md index 6e17edb..ed2cacf 100644 --- a/README.en.md +++ b/README.en.md @@ -173,6 +173,23 @@ You can sign in with a ChatGPT account instead of an API key to use it as the mi > [!WARNING] > Audio recording and transcription (both the Qwen3 and WhisperX presets) stay on your computer. When you select `AI 증강 실행`, the transcript, participants selected for this meeting, glossary, and background context are sent to the configured external API (the OpenAI API when ChatGPT sign-in is used). Review the provider's security and retention policy before using this feature with sensitive meetings. +### Connect Google Drive (optional) + +You can import recordings stored in Drive and have the results uploaded to a `Galpi` folder in your Drive automatically. Everything else works without connecting. + +1. After signing in, select `Drive 연결` (Connect Drive) on the **Google Drive 연결** (Connect Google Drive) card. If you dismissed the card, connect from **Google Drive** in `설정` (Settings). +2. Allow Drive access in the system browser. Galpi then creates a `Galpi` folder in your Drive (or reuses the existing one) and shows the connected account in the top bar. +3. Select `Drive에서 가져오기` (Import from Drive) to open the Google Picker in the browser. Pick one recording; Galpi downloads it to this computer, and `전사 시작` (Start transcription) transcribes it. A file picked with a different account than the connected one is not imported. +4. While Drive is connected, every finished transcription, transcript import, and AI augmentation uploads its results (`.srt`, `_화자별.txt`, `_회의록.md`) to `Galpi/`. Processing the same meeting again updates the same files instead of creating new ones. + +- The only scope is `drive.file`: Galpi can reach files it created and files you picked in the Picker, and nothing else in your Drive. +- If a file changed in Drive since Galpi last uploaded it, Galpi does not overwrite it and reports a conflict. It overwrites only when you select `Drive 버전 덮어쓰기` (Overwrite the Drive version) in the results area. +- A failed upload leaves the local results untouched and is shown separately in the results area. The app does not resend a failed upload. +- The original file you picked is only read; it is never moved or deleted. The downloaded copy lives in the app cache's `drive-imports` folder and is removed on the next launch. +- `설정` (Settings) → **Google Drive** → `연결 해제` (Disconnect) asks Google to revoke the token and removes the local connection. Files uploaded to Drive stay there. +- The Galpi sign-in server (auth-gateway) performs the code exchange and token refresh. The app source contains no OAuth client secret. +- Privacy policy: + ## Processing stages and models Recording through transcription runs locally; the transcript leaves the machine only when you select `AI 증강 실행`. The stage order is the same on every OS; only the speech recognition engine and compute device differ. There is no Linux build. @@ -229,6 +246,7 @@ The completion screen can open each artifact or reveal its output folder in Find - Audio and transcription artifacts are stored in the local folder you choose. - Transcription, alignment, and diarization models (Qwen3, WhisperX, pyannote) are stored in Galpi's app-specific Hugging Face cache. - The Hugging Face token, the AI augmentation API key, the ChatGPT sign-in tokens, and the Google sign-in session (auth-gateway) are stored differently per platform. On Windows they live in Windows Credential Manager under `com.m16khb.galpi:hugging-face-token`, `com.m16khb.galpi:assistant-api-key`, `com.m16khb.galpi:chatgpt-tokens`, and `com.m16khb.galpi:gateway-session`; long values are split across several entries. On macOS they are stored in the Application Support settings file (`settings.json`) with `0600` permissions. The ChatGPT access and refresh tokens and the Google sign-in session are kept in that file in plain text, so a leaked file can expose API access for the account or the Galpi sign-in. The signed-in Google account's email is kept in `settings.json` on both platforms. +- The Google Drive tokens follow the same path: the `com.m16khb.galpi:drive-session` entry on Windows, and plain text in `settings.json` on macOS. The connected Drive account's email and the `Galpi` folder id are kept in `settings.json` on both platforms. - On macOS credentials are not encrypted with the Keychain yet. The Keychain ties access to an item to the app's code signature, and ad-hoc signing changes that signature on every build, so each update would ask users to re-authorize a token they never touched. The macOS move to the Keychain lands together with Developer ID signing. - Transcripts are not sent to an external LLM API unless AI minutes are run. - The worker launches fixed programs with explicit argv and does not execute shell strings. diff --git a/README.md b/README.md index 672da13..609c06d 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,23 @@ API Key 대신 ChatGPT 계정으로 로그인해 회의록 정제 모델을 사 > [!WARNING] > 음성 녹음과 전사(Qwen3·WhisperX 모두)는 로컬에서 처리됩니다. `AI 증강 실행`을 누르면 전사문, 이번 회의에서 선택한 참석자, 단어집, 사전 정보가 설정한 외부 API(ChatGPT 로그인 사용 시 OpenAI API)로 전송됩니다. 민감한 회의에서는 사용 중인 API 제공자의 보안·보존 정책을 먼저 확인하세요. +### Google Drive 연결 (선택) + +Drive에 올려 둔 녹음을 가져와 전사하고, 결과를 내 Drive의 `Galpi` 폴더에 자동으로 올릴 수 있습니다. 연결하지 않아도 다른 기능은 그대로 쓸 수 있습니다. + +1. 로그인하면 나타나는 **Google Drive 연결** 카드에서 `Drive 연결`을 누릅니다. 카드를 닫았다면 `설정`의 **Google Drive**에서 연결합니다. +2. 시스템 브라우저에서 Drive 권한을 허용합니다. 연결이 끝나면 내 Drive에 `Galpi` 폴더가 생기고(이미 있으면 그 폴더를 씁니다), 상단에 연결한 계정이 표시됩니다. +3. `Drive에서 가져오기`를 누르면 브라우저에서 Google Picker가 열립니다. 녹음 파일을 하나 고르면 이 컴퓨터로 내려받고, `전사 시작`으로 전사할 수 있습니다. Drive에 연결한 계정과 다른 계정에서 고른 파일은 가져오지 않습니다. +4. Drive가 연결되어 있으면 전사, 전사문 가져오기, AI 증강이 끝날 때마다 결과(`.srt`, `_화자별.txt`, `_회의록.md`)가 `Galpi/<회의 이름>` 폴더에 올라갑니다. 같은 회의를 다시 처리하면 새 파일을 만들지 않고 같은 파일을 갱신합니다. + +- 권한 범위는 `drive.file` 하나입니다. Galpi는 직접 만든 파일과 Picker에서 직접 고른 파일에만 접근하며, Drive의 다른 파일은 볼 수 없습니다. +- 마지막으로 올린 뒤 Drive에서 파일이 바뀌었으면 덮어쓰지 않고 충돌로 알립니다. 결과 영역의 `Drive 버전 덮어쓰기`를 눌러야 덮어씁니다. +- 업로드가 실패해도 로컬 결과는 그대로 남고, 실패는 결과 영역에 따로 표시됩니다. 앱은 실패한 업로드를 자동으로 다시 보내지 않습니다. +- Drive에서 고른 원본은 읽기만 하고 옮기거나 지우지 않습니다. 내려받은 사본은 앱 캐시의 `drive-imports` 폴더에 두며, 앱을 다시 실행하면 지웁니다. +- `설정` → **Google Drive** → `연결 해제`를 누르면 Google에 토큰 폐기를 요청하고 로컬 연결 정보를 지웁니다. Drive에 올린 파일은 지우지 않습니다. +- 코드 교환과 토큰 갱신은 Galpi 로그인 서버(auth-gateway)가 대신 처리합니다. 앱 소스에는 OAuth 클라이언트 보안 비밀번호가 없습니다. +- 개인정보처리방침: + ## 처리 단계와 사용 모델 녹음부터 전사까지는 모두 로컬에서 처리하고, `AI 증강 실행`을 눌렀을 때만 전사문을 외부 API로 보냅니다. 단계 순서는 OS와 관계없이 같으며, OS에 따라 달라지는 것은 음성 인식 엔진과 연산 장치입니다. Linux용 빌드는 제공하지 않습니다. @@ -229,6 +246,7 @@ API Key 대신 ChatGPT 계정으로 로그인해 회의록 정제 모델을 사 - 음성과 전사 산출물은 사용자가 선택한 로컬 폴더에 저장됩니다. - 전사·정렬·화자분리 모델(Qwen3, WhisperX, pyannote)은 Galpi의 앱 전용 Hugging Face 캐시에 저장됩니다. - Hugging Face 토큰, AI 증강 API 키, ChatGPT 로그인 토큰, Google 로그인 세션(auth-gateway)은 플랫폼에 따라 다르게 저장됩니다. Windows에서는 Windows Credential Manager의 `com.m16khb.galpi:hugging-face-token`, `com.m16khb.galpi:assistant-api-key`, `com.m16khb.galpi:chatgpt-tokens`, `com.m16khb.galpi:gateway-session` 항목에 저장되며, 긴 값은 여러 항목에 나누어 저장됩니다. macOS에서는 Application Support 아래 설정 파일(`settings.json`)에 `0600` 권한으로 저장됩니다. ChatGPT 토큰(access·refresh)과 Google 로그인 세션도 이 파일에 평문으로 들어가므로, 파일이 유출되면 계정의 API 접근이나 Galpi 로그인이 노출될 수 있습니다. 로그인한 Google 계정의 이메일은 두 플랫폼 모두 `settings.json`에 저장됩니다. +- Google Drive 토큰도 같은 경로로 저장됩니다. Windows에서는 `com.m16khb.galpi:drive-session` 항목에, macOS에서는 `settings.json`에 평문으로 저장됩니다. 연결한 Drive 계정의 이메일과 `Galpi` 폴더 ID는 두 플랫폼 모두 `settings.json`에 저장됩니다. - macOS에서는 자격 증명을 아직 Keychain으로 암호화하지 않습니다. Keychain은 항목 접근 권한을 앱의 코드 서명에 묶는데, 현재 빌드는 ad-hoc 서명이라 빌드마다 서명이 달라져 사용자가 업데이트할 때마다 접근 허용 창을 다시 보게 됩니다. macOS의 Keychain 전환은 Developer ID 서명과 함께 적용할 예정입니다. - AI 회의록을 실행하지 않으면 전사문은 외부 LLM API로 전송되지 않습니다. - worker는 고정된 프로그램과 argv로 실행되며 셸 문자열을 실행하지 않습니다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b25d458..da14d07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,12 +16,12 @@ Galpi는 세 개의 런타임으로 구성된다. 각각 내부적으로 같은 TypeScript (WebView) Rust (Tauri 호스트) Python (WhisperX 사이드카) ┌────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐ │ ui/ (외부) │ │ adapters/inbound (외부) │ │ __main__.py (외부) │ -│ ├─ controller │ IPC │ ├─ tauri.rs (28 커맨드) │ │ engine.py (유스케이스)│ +│ ├─ controller │ IPC │ ├─ tauri.rs (35 커맨드) │ │ engine.py (유스케이스)│ │ ├─ app-view │ ─────▶ │ └─ TauriEvents │ │ refine.py │ │ └─ settings/… │ │ │ │ protocol.py (포트: stdout)│ ├────────────────────┤ │ application/ (유스케이스) │ ├────────────────────────┤ │ application/ │ │ ├─ use_cases.rs (facade) │ │ domain 순수 모듈 │ -│ ├─ job-machine │ │ ├─ ports.rs (15개 포트) │ │ ├─ core.py │ +│ ├─ job-machine │ │ ├─ ports.rs (18개 포트) │ │ ├─ core.py │ │ └─ recording-machine│ │ └─ jobs.rs (registry) │ │ ├─ artifacts.py │ ├────────────────────┤ ├──────────────────────────┤ │ ├─ minutes_*.py │ │ domain/ │ │ domain/ (내부) │ │ └─ assistant_stream.py │ @@ -50,11 +50,11 @@ TypeScript (WebView) Rust (Tauri 호스트) Python (Whispe | 방향 | Rust | TypeScript | Python | |------|------|-----------|--------| -| **-driving (inbound)** | `adapters/inbound/tauri.rs` — 28개 `#[tauri::command]`(`save_compute_device` 포함, `composition.rs`의 `generate_handler!`와 일치) + `TauriEvents` 이벤트 브리지(`job-event`·`recording-event`·`chatgpt-event`) | `adapters/tauri-backend.ts` — `BackendPort` 구현 + Zod 스키마 검증 | `__main__.py` CLI 인자 파싱 | -| **-driven (outbound)** | `DesktopAdapter`(엔진·전사·산출물), `NativeRecorder`(CPAL), `LocalSettingsStore`(`ChatGptStore`·`GatewaySessionStore` 포함), `ChatGptOAuthAdapter`(`ChatGptAuthPort`: OAuth/PKCE·토큰 갱신·폐기·모델 목록), `GatewayOAuthAdapter`(`GatewayAuthPort`: auth-gateway Google 로그인·세션 갱신·로그아웃), 두 로그인 어댑터가 함께 쓰는 `browser_sign_in/`(시스템 브라우저·루프백·PKCE), `SystemClock`(`ClockPort`), `process.rs`(워커 감독) | (없음 — 프론트엔드는 driven 포트가 없다; 브라우저 API는 어댑터 내부 처리) | `protocol.py` `EventWriter`(stdout), `assistant_stream.py`·`responses_stream.py`(HTTP) | +| **-driving (inbound)** | `adapters/inbound/tauri.rs` — 35개 `#[tauri::command]`(`save_compute_device`와 Drive 7개 포함, `composition.rs`의 `generate_handler!`와 일치) + `TauriEvents` 이벤트 브리지(`job-event`·`recording-event`·`chatgpt-event`) | `adapters/tauri-backend.ts` — `BackendPort` 구현 + Zod 스키마 검증 | `__main__.py` CLI 인자 파싱 | +| **-driven (outbound)** | `DesktopAdapter`(엔진·전사·산출물), `NativeRecorder`(CPAL), `LocalSettingsStore`(`ChatGptStore`·`GatewaySessionStore`·`DriveSessionStore` 포함), `ChatGptOAuthAdapter`(`ChatGptAuthPort`: OAuth/PKCE·토큰 갱신·폐기·모델 목록), `GatewayOAuthAdapter`(`GatewayAuthPort`: auth-gateway Google 로그인·세션 갱신·로그아웃), `DriveOAuthAdapter`(`DriveAuthPort`: `drive.file` 동의·데스크톱 Picker, 코드 교환·갱신은 auth-gateway `/drive/native/token` 대행), `DriveRestAdapter`(`DriveFilesPort`: Drive v3 폴더·업로드·갱신·스트리밍 다운로드), 세 로그인 어댑터가 함께 쓰는 `browser_sign_in/`(시스템 브라우저·루프백·PKCE), `SystemClock`(`ClockPort`), `process.rs`(워커 감독) | (없음 — 프론트엔드는 driven 포트가 없다; 브라우저 API는 어댑터 내부 처리) | `protocol.py` `EventWriter`(stdout), `assistant_stream.py`·`responses_stream.py`(HTTP) | **포트 소유 규칙 (DIP)**: 포트 인터페이스는 사용하는 쪽(내부 계층)이 소유하고, -어댑터가 그것을 구현한다. Rust는 `application/ports.rs`에 15개 trait이 있어 정확히 지켜진다. +어댑터가 그것을 구현한다. Rust는 `application/ports.rs`에 18개 trait이 있어 정확히 지켜진다. TypeScript는 `BackendPort`가 **어댑터 모듈에** 정의되어 있어 위반이었다 — 리팩토링으로 `domain/backend.ts`로 옮겼다(§6). 프론트엔드의 `ui → adapters` 의존은 이제 타입 재수출 경로(`domain/backend`)로만 존재한다. @@ -71,7 +71,8 @@ FFI(`process/guard/windows.rs`, `recording/power/windows.rs`, `scripts/check-architecture.ts`는 domain/application/inbound에서 `cfg(windows)`, `cfg(unix)`, `target_os`, `cfg!(`를 금지한다. 빌드 종류에 따른 분기도 같은 규칙을 따른다: 디버그 빌드에서만 `GALPI_AUTH_GATEWAY_URL`로 로그인 서버를 바꾸는 `cfg!(debug_assertions)`는 -`adapters/outbound/gateway/`에만 있다. +`adapters/outbound/gateway/`에만 있다. Drive 어댑터는 같은 `gateway_url()`을 빌려 쓰므로 +한 번의 덮어쓰기로 두 경로가 함께 로컬 gateway를 가리킨다. ## 3. DDD 관점 — 전술적 패턴 매핑 @@ -98,7 +99,7 @@ CPAL은 도메인에 금지다. | **S**RP | 유스케이스 메서드는 하나의 사용자 의도만; `process.rs`는 감독만, `writer.rs`는 WAV 직렬화만; TS 설정 위젯은 화면 하나씩 | 250 LOC 순수 코드 상향선 초과 시 모듈 분리 | | **O**CP | 이벤트/요청은 tagged union + `switch`/`match`로 확장 — 새 이벤트 추가는 새 variant 추가와 전 매치 컴파일 오류로 유도 (Rust `match`는 exhaustive) | `default:` 분기로 확장을 흡수하지 않는다 | | **L**SP | 포트 구현체(`FakePort`, `TauriBackend`, `DesktopAdapter`)는 계약(오류 코드, 이벤트 순서)을 지킨다 | 테스트 페이크가 프로덕션 계약과 어긋나면 페이크를 고친다 | -| **I**SP | Rust 포트 15개는 소비자별 분리(`EnginePort` ≠ `ArtifactPort` ≠ `SettingsPort` ≠ `ChatGptAuthPort` ≠ `GatewayAuthPort`…); TS `BackendPort`는 단일 소비자(controller)를 위한 하나의 응집된 계약 | 컨트롤러가 사용하지 않는 메서드를 포트에 추가하지 않는다 | +| **I**SP | Rust 포트 18개는 소비자별 분리(`EnginePort` ≠ `ArtifactPort` ≠ `SettingsPort` ≠ `ChatGptAuthPort` ≠ `GatewayAuthPort` ≠ `DriveFilesPort`…); TS `BackendPort`는 단일 소비자(controller)를 위한 하나의 응집된 계약 | 컨트롤러가 사용하지 않는 메서드를 포트에 추가하지 않는다 | | **D**IP | 내부 계층은 포트만 안다: Rust `Arc`, TS `BackendPort` 타입 주입, 워커는 `EventWriter` 주입 | `ui/`가 `@tauri-apps/*` 또는 `adapters/` 구현체를 import하면 `check-architecture.ts`가 실패한다 | ## 5. OOP 구조 관례 @@ -146,7 +147,8 @@ Rust는 전통적 상속이 없으므로 OOP 원칙은 **trait + 조합**으로 `TauriEvents`에 해당 `*Events` 포트 구현과 `tauri-backend.ts`의 `listen` 파싱을 함께 갱신한다. 3. **새 외부 능력**: `application/ports.rs`에 trait → outbound 구현 → `composition.rs` wiring (ChatGPT 로그인 계열은 `ChatGptAuthPort`/`ChatGptStore`/`ChatGptEvents`/`ClockPort`, - 앱 사용 로그인은 `GatewayAuthPort`/`GatewaySessionStore`) + 앱 사용 로그인은 `GatewayAuthPort`/`GatewaySessionStore`, Google Drive는 + `DriveAuthPort`/`DriveFilesPort`/`DriveSessionStore`) → `application/tests.rs`의 `FakePort` 확장. 4. **새 UI 상태**: `application/*-machine.ts`에 리듀서 + 동반 테스트; 뷰는 렌더만. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 9b6aae7..19eedc5 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,7 +11,8 @@ "identifier": "opener:allow-open-url", "allow": [ { "url": "https://huggingface.co/*" }, - { "url": "https://chatgpt.com/settings/usage" } + { "url": "https://chatgpt.com/settings/usage" }, + { "url": "https://drive.google.com/*" } ] } ] diff --git a/src-tauri/src/adapters/inbound/tauri.rs b/src-tauri/src/adapters/inbound/tauri.rs index ae47db6..58e789b 100644 --- a/src-tauri/src/adapters/inbound/tauri.rs +++ b/src-tauri/src/adapters/inbound/tauri.rs @@ -9,6 +9,7 @@ use crate::domain::artifact::ArtifactKind; use crate::domain::chatgpt::{ ChatGptModel, ChatGptPreferences, ChatGptSettings, ChatGptSignInEvent, SignOutResult, }; +use crate::domain::drive::{DriveImport, DriveStatus, DriveSyncResult}; use crate::domain::engine::{ComputeDevice, EnginePreset}; use crate::domain::gateway::AppAccess; use crate::domain::job::{SetupRequest, TranscriptImportRequest, TranscriptionRequest}; @@ -236,6 +237,59 @@ pub async fn sign_out_of_gateway(application: State<'_, Application>) -> Result< application.sign_out_of_gateway().await } +/// Whether Drive is connected and, if not, whether the onboarding card was dismissed. +#[tauri::command] +pub async fn load_drive_status( + application: State<'_, Application>, +) -> Result { + application.load_drive_status().await +} + +/// Opens the system browser for the `drive.file` consent and resolves once +/// the connection is stored, fails, is cancelled, or times out. +#[tauri::command] +pub async fn connect_drive(application: State<'_, Application>) -> Result { + application.connect_drive().await +} + +#[tauri::command] +pub async fn cancel_drive_operation(application: State<'_, Application>) -> Result<(), AppError> { + application.cancel_drive_operation() +} + +#[tauri::command] +pub async fn disconnect_drive( + application: State<'_, Application>, +) -> Result { + application.disconnect_drive().await +} + +#[tauri::command] +pub async fn dismiss_drive_onboarding( + application: State<'_, Application>, +) -> Result { + application.dismiss_drive_onboarding().await +} + +/// Opens the desktop Picker and resolves once the chosen recording is on disk. +/// `cancel_job` with the same id stops the wait or the download. +#[tauri::command] +pub async fn import_from_drive( + application: State<'_, Application>, + job_id: Uuid, +) -> Result { + application.import_from_drive(job_id).await +} + +#[tauri::command] +pub async fn sync_meeting_to_drive( + application: State<'_, Application>, + job_id: Uuid, + overwrite: bool, +) -> Result { + application.sync_meeting_to_drive(job_id, overwrite).await +} + #[derive(Debug, Clone)] pub struct TauriEvents { app: AppHandle, diff --git a/src-tauri/src/adapters/outbound/AGENTS.md b/src-tauri/src/adapters/outbound/AGENTS.md index 8ee2e7a..165bf63 100644 --- a/src-tauri/src/adapters/outbound/AGENTS.md +++ b/src-tauri/src/adapters/outbound/AGENTS.md @@ -12,8 +12,10 @@ cleanup, and stable error codes are the contract here. outbound/ ├── browser_sign_in/ # Shared system-browser sign-in: TauriBrowser, loopback callback, PKCE; callers pass SignInCodes ├── chatgpt/ # ChatGptAuthPort: OAuth authorize/callback, ID-token, /v1/models; SystemClock +├── drive/ # DriveAuthPort (consent, Picker, gateway-brokered exchange) and DriveFilesPort (Drive v3 REST, streamed download) ├── gateway/ # GatewayAuthPort: auth-gateway /auth/native Google sign-in, refresh, logout ├── settings/chatgpt.rs # ChatGptStore on LocalSettingsStore (tokens in 0600 settings.json) +├── settings/drive.rs # DriveSessionStore on LocalSettingsStore (Secret::DriveSession) ├── settings/gateway.rs # GatewaySessionStore on LocalSettingsStore (Secret::GatewaySession) ├── desktop.rs # EnginePort, TranscriptionPort, TranscriptImportPort, ArtifactPort facade ├── environment.rs # Readiness probe and worker environment diff --git a/src-tauri/src/adapters/outbound/browser_sign_in/fake_server.rs b/src-tauri/src/adapters/outbound/browser_sign_in/fake_server.rs index 1861e01..29cd095 100644 --- a/src-tauri/src/adapters/outbound/browser_sign_in/fake_server.rs +++ b/src-tauri/src/adapters/outbound/browser_sign_in/fake_server.rs @@ -9,6 +9,7 @@ pub struct Recorded { pub method: String, pub path: String, pub authorization: Option, + pub content_type: Option, pub body: String, } @@ -20,6 +21,19 @@ impl Recorded { .find(|(key, _)| key == name) .map(|(_, value)| value.into_owned()) } + + /// The request path without its query. + pub fn route(&self) -> &str { + self.path.split('?').next().unwrap_or_default() + } + + /// A decoded query parameter of the request target. + pub fn query(&self, name: &str) -> Option { + let url = reqwest::Url::parse(&format!("http://x{}", self.path)).ok()?; + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) + } } pub type Handler = Arc (u16, String) + Send + Sync>; @@ -102,6 +116,11 @@ async fn serve(mut stream: TcpStream, handler: Handler, log: Arc Result { + let mut url = + Url::parse(base).map_err(|_| connect_failed("Drive 연결 주소를 만들지 못했습니다."))?; + { + let mut query = url.query_pairs_mut(); + query + .append_pair("client_id", CLIENT_ID) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", SCOPE) + .append_pair("code_challenge", &code_challenge(verifier)) + .append_pair("code_challenge_method", "S256") + .append_pair("state", state) + .append_pair("access_type", "offline") + // The desktop Picker requires it; with offline access it also makes + // Google return a refresh token. + .append_pair("prompt", "consent"); + if request.picker { + query + .append_pair("trigger_onepick", "true") + .append_pair("mimetypes", PICKER_MIME_TYPES); + } + if let Some(hint) = &request.login_hint { + query.append_pair("login_hint", hint); + } + } + Ok(url.into()) +} + +/// The code and the picked files of a callback that answers this request. +pub(super) fn read_callback( + callback: &Callback, + state: &str, +) -> Result<(String, Vec), AppError> { + if callback.get("state") != Some(state) { + return Err(AppError::new( + "DRIVE_STATE_MISMATCH", + "Google 응답이 이 요청과 맞지 않아 Drive 연결을 마치지 못했습니다. 다시 시도해 주세요.", + )); + } + match callback.get("error") { + Some("access_denied") => { + return Err(AppError::new("CANCELLED", "사용자가 작업을 취소했습니다.")); + } + Some(_) => { + return Err(connect_failed( + "Google에서 Drive 권한을 받지 못했습니다. 다시 시도해 주세요.", + )); + } + None => {} + } + let code = callback + .get("code") + .filter(|code| !code.is_empty()) + .ok_or_else(|| connect_failed("Google 응답에 인증 코드가 없습니다. 다시 시도해 주세요."))?; + let picked = + parse_picked_ids(callback.get("picked_file_ids").unwrap_or_default()).map_err(|_| { + AppError::new( + "DRIVE_PICK_INVALID", + "Drive에서 고른 파일 정보를 읽지 못했습니다. 다시 골라 주세요.", + ) + })?; + Ok((code.to_owned(), picked)) +} diff --git a/src-tauri/src/adapters/outbound/drive/mod.rs b/src-tauri/src/adapters/outbound/drive/mod.rs new file mode 100644 index 0000000..531d013 --- /dev/null +++ b/src-tauri/src/adapters/outbound/drive/mod.rs @@ -0,0 +1,184 @@ +//! Google Drive for the desktop (RFC 8252): `drive.file` consent and the +//! desktop Picker through the system browser and a loopback redirect with +//! PKCE, the code exchange and renewal the auth-gateway performs for Galpi +//! (the desktop client secret lives only on the gateway), and Drive v3 calls. +//! +//! Nothing here logs a token, `code`, `state`, or consent URL, and no failed +//! request is retried. + +mod authorize; +mod rest; +mod tokens; + +#[cfg(test)] +mod tests; + +pub use rest::DriveRestAdapter; + +use super::browser_sign_in::loopback::{Loopback, PREFERRED_PORT, SIGN_IN_TIMEOUT}; +use super::browser_sign_in::pkce::{NONCE_BYTES, VERIFIER_BYTES, random_urlsafe}; +use super::browser_sign_in::{BrowserOpener, SignInCodes}; +use super::gateway::gateway_url; +use crate::application::error::AppError; +use crate::application::ports::{ClockPort, DriveAuthPort, DriveAuthorize, DriveCallback}; +use crate::domain::drive::{DriveTokenFailure, DriveTokens}; +use async_trait::async_trait; +use reqwest::{Client, redirect::Policy}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; + +/// The `Galpi Desktop` OAuth client of the `galpi-511214` Cloud project. +/// A desktop client id is public; its secret stays on the auth-gateway. +const CLIENT_ID: &str = "747212408320-1io6clrt6t568ot424pv54lepsvfl9pf.apps.googleusercontent.com"; +/// The only scope Galpi asks for: files it created or the user picked. +const SCOPE: &str = "https://www.googleapis.com/auth/drive.file"; +const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_REVOKE_URL: &str = "https://oauth2.googleapis.com/revoke"; +const DRIVE_API_URL: &str = "https://www.googleapis.com/drive/v3"; +const DRIVE_UPLOAD_URL: &str = "https://www.googleapis.com/upload/drive/v3"; + +const CODES: SignInCodes = SignInCodes { + failed: "DRIVE_CONNECT_FAILED", + timed_out: "DRIVE_CONNECT_TIMEOUT", +}; + +fn connect_failed(message: &str) -> AppError { + AppError::new(CODES.failed, message) +} + +/// Every endpoint and limit that production fixes and tests shorten. +pub struct Tuning { + pub gateway_url: String, + pub google_auth_url: String, + pub google_revoke_url: String, + pub drive_api_url: String, + pub drive_upload_url: String, + pub preferred_port: u16, + pub sign_in_timeout: Duration, +} + +impl Tuning { + fn production() -> Self { + Self { + gateway_url: gateway_url(), + google_auth_url: GOOGLE_AUTH_URL.to_owned(), + google_revoke_url: GOOGLE_REVOKE_URL.to_owned(), + drive_api_url: DRIVE_API_URL.to_owned(), + drive_upload_url: DRIVE_UPLOAD_URL.to_owned(), + preferred_port: PREFERRED_PORT, + sign_in_timeout: SIGN_IN_TIMEOUT, + } + } +} + +pub struct DriveOAuthAdapter { + http: Client, + tuning: Tuning, + browser: Arc, + clock: Arc, +} + +impl DriveOAuthAdapter { + pub fn new( + browser: Arc, + clock: Arc, + ) -> Result { + Self::with_tuning(Tuning::production(), browser, clock) + } + + fn with_tuning( + tuning: Tuning, + browser: Arc, + clock: Arc, + ) -> Result { + let http = Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .redirect(Policy::none()) + .build() + .map_err(|_| connect_failed("Drive 연결을 준비하지 못했습니다."))?; + Ok(Self { + http, + tuning, + browser, + clock, + }) + } + + fn token_endpoint(&self) -> String { + format!("{}/drive/native/token", self.tuning.gateway_url) + } +} + +#[async_trait] +impl DriveAuthPort for DriveOAuthAdapter { + async fn authorize( + &self, + request: &DriveAuthorize, + cancel: &mut oneshot::Receiver<()>, + ) -> Result { + let state = random_urlsafe(NONCE_BYTES, CODES)?; + let verifier = random_urlsafe(VERIFIER_BYTES, CODES)?; + let loopback = Loopback::bind_preferring(self.tuning.preferred_port).await?; + let redirect_uri = loopback.redirect_uri(); + let url = authorize::consent_url( + &self.tuning.google_auth_url, + request, + &redirect_uri, + &verifier, + &state, + )?; + self.browser.open(&url, CODES)?; + let callback = loopback + .wait(cancel, self.tuning.sign_in_timeout, CODES) + .await?; + let (code, picked) = authorize::read_callback(&callback, &state)?; + Ok(DriveCallback { + code, + verifier, + redirect_uri, + picked, + }) + } + + async fn exchange( + &self, + bearer: &str, + callback: &DriveCallback, + ) -> Result { + tokens::exchange( + &self.http, + &self.token_endpoint(), + bearer, + callback, + self.clock.now_unix(), + ) + .await + } + + async fn refresh( + &self, + bearer: &str, + refresh_token: &str, + ) -> Result { + tokens::refresh( + &self.http, + &self.token_endpoint(), + bearer, + refresh_token, + self.clock.now_unix(), + ) + .await + } + + async fn revoke(&self, refresh_token: &str) { + // Best effort: the local connection is forgotten whatever Google says. + let _answered = self + .http + .post(&self.tuning.google_revoke_url) + .form(&[("token", refresh_token)]) + .send() + .await; + } +} diff --git a/src-tauri/src/adapters/outbound/drive/rest.rs b/src-tauri/src/adapters/outbound/drive/rest.rs new file mode 100644 index 0000000..72d8ce8 --- /dev/null +++ b/src-tauri/src/adapters/outbound/drive/rest.rs @@ -0,0 +1,760 @@ +//! Drive v3 calls with the user's `drive.file` access token. +//! +//! Galpi marks what it creates with `appProperties` (`galpiRoot`, +//! `galpiMeeting`, `galpiKind`, `galpiSha256`) and finds meeting folders and +//! result files again by querying them; only the root folder id is kept with +//! the connection. + +use super::super::paths::{AppPaths, canonical, sanitized_stem}; +use super::Tuning; +use crate::application::error::AppError; +use crate::application::ports::{DriveFilesPort, LocalUpload, PickedFile, RemoteFolder}; +use crate::domain::artifact::ArtifactKind; +use crate::domain::drive::{DriveAccount, DriveFileId, MEETING_PROPERTY, MeetingKey, RemoteFile}; +use async_trait::async_trait; +use reqwest::header::CONTENT_TYPE; +use reqwest::{Client, RequestBuilder, Response, StatusCode, Url}; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; +use tauri::AppHandle; +use tokio::io::AsyncWriteExt; +use tokio::sync::oneshot; +use tokio::time::timeout; +use uuid::Uuid; + +/// Lowercase hex SHA-256, the form Drive reports in `sha256Checksum`. +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +const FOLDER_MIME: &str = "application/vnd.google-apps.folder"; +const ROOT_FOLDER_NAME: &str = "Galpi"; +const ROOT_PROPERTY: &str = "galpiRoot"; +const KIND_PROPERTY: &str = "galpiKind"; +const SHA_PROPERTY: &str = "galpiSha256"; +/// Drive's limit for a single multipart upload. +const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +/// A download may take as long as it needs, but neither its first answer nor +/// any later chunk may stall longer than this. +const CHUNK_TIMEOUT: Duration = Duration::from_secs(60); + +fn unavailable() -> AppError { + AppError::new( + "DRIVE_UNAVAILABLE", + "Google Drive에 연결하지 못했습니다. 잠시 뒤 다시 시도해 주세요.", + ) +} + +fn unreadable() -> AppError { + AppError::new( + "DRIVE_REQUEST_FAILED", + "Google Drive 응답 형식을 알 수 없습니다.", + ) +} + +fn cancelled() -> AppError { + AppError::new("CANCELLED", "사용자가 작업을 취소했습니다.") +} + +fn download_failed() -> AppError { + AppError::new( + "DRIVE_DOWNLOAD_FAILED", + "Drive에서 이 파일을 내려받지 못했습니다.", + ) +} + +/// `{"error": {"errors": [{"reason": ".."}]}}` +#[derive(Deserialize)] +struct ErrorEnvelope { + error: ErrorDetail, +} + +#[derive(Deserialize)] +struct ErrorDetail { + #[serde(default)] + errors: Vec, +} + +#[derive(Deserialize)] +struct ErrorReason { + #[serde(default)] + reason: String, +} + +fn reasons(body: &[u8]) -> Vec { + serde_json::from_slice::(body) + .map(|envelope| { + envelope + .error + .errors + .into_iter() + .map(|error| error.reason) + .collect() + }) + .unwrap_or_default() +} + +/// Map a Drive error answer to a stable code. Never retried here. +fn api_error(status: StatusCode, body: &[u8]) -> AppError { + let reasons = reasons(body); + let has = |reason: &str| reasons.iter().any(|candidate| candidate == reason); + if status == StatusCode::UNAUTHORIZED { + return AppError::new( + "DRIVE_TOKEN_REJECTED", + "Google Drive가 연결을 받아들이지 않았습니다.", + ); + } + if status == StatusCode::FORBIDDEN && has("storageQuotaExceeded") { + return AppError::new( + "DRIVE_STORAGE_FULL", + "Google Drive 저장 공간이 부족해 올리지 못했습니다.", + ); + } + if status == StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() + || (status == StatusCode::FORBIDDEN + && (has("rateLimitExceeded") || has("userRateLimitExceeded"))) + { + return unavailable(); + } + if status == StatusCode::NOT_FOUND { + return AppError::new( + "DRIVE_NOT_FOUND", + "Google Drive에서 파일을 찾지 못했습니다.", + ); + } + AppError::new( + "DRIVE_REQUEST_FAILED", + "Google Drive가 요청을 처리하지 못했습니다.", + ) +} + +fn parse(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|_| unreadable()) +} + +/// Escape a value for a single-quoted Drive `q` string. +fn quote(value: &str) -> String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} + +fn has_property(key: &str, value: &str) -> String { + format!( + "appProperties has {{ key='{}' and value='{}' }}", + quote(key), + quote(value) + ) +} + +const fn kind_name(kind: ArtifactKind) -> &'static str { + match kind { + ArtifactKind::Srt => "srt", + ArtifactKind::SpeakerText => "speaker_text", + ArtifactKind::Checkpoint => "checkpoint", + ArtifactKind::Minutes => "minutes", + } +} + +const fn mime_type(kind: ArtifactKind) -> &'static str { + match kind { + ArtifactKind::Srt => "application/x-subrip", + ArtifactKind::SpeakerText => "text/plain", + ArtifactKind::Checkpoint => "application/json", + ArtifactKind::Minutes => "text/markdown", + } +} + +fn file_properties(key: &MeetingKey, kind: ArtifactKind, sha256: &str) -> Value { + json!({ + MEETING_PROPERTY: key.as_str(), + KIND_PROPERTY: kind_name(kind), + SHA_PROPERTY: sha256, + }) +} + +/// A `multipart/related` body: JSON metadata, then the content. +fn multipart(metadata: &Value, mime: &str, content: &[u8]) -> (String, Vec) { + let boundary = format!("galpi-{}", Uuid::new_v4().simple()); + let mut body = Vec::with_capacity(content.len() + 512); + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata}\r\n\ +--{boundary}\r\nContent-Type: {mime}\r\n\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(content); + body.extend_from_slice(format!("\r\n--{boundary}--").as_bytes()); + (format!("multipart/related; boundary={boundary}"), body) +} + +/// A file name safe on every platform, keeping the extension. Drive names are +/// plain strings, so they are never parsed as host paths: both `/` and `\` +/// separate segments on every OS, and a Windows drive prefix is ordinary text. +fn local_name(name: &str) -> String { + let base = name + .rsplit(['/', '\\']) + .find(|segment| !segment.is_empty() && *segment != ".") + .filter(|segment| *segment != "..") + .unwrap_or_default(); + let (stem, extension) = match base.rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() => (stem, extension), + _ => (base, ""), + }; + let stem = sanitized_stem(stem); + if !extension.is_empty() && extension.bytes().all(|byte| byte.is_ascii_alphanumeric()) { + format!("{stem}.{extension}") + } else { + stem + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileBody { + id: Option, + #[serde(default)] + trashed: bool, + web_view_link: Option, + sha256_checksum: Option, + #[serde(default)] + app_properties: HashMap, + name: Option, + mime_type: Option, + capabilities: Option, + modified_time: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Capabilities { + #[serde(default)] + can_download: bool, +} + +#[derive(Deserialize)] +struct FileList { + #[serde(default)] + files: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AboutBody { + user: AboutUser, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AboutUser { + email_address: String, + permission_id: String, +} + +fn required_id(body: FileBody) -> Result { + body.id.filter(|id| !id.is_empty()).ok_or_else(unreadable) +} + +fn remote_file(mut body: FileBody, synced_sha256: Option) -> Result { + let synced_sha256 = synced_sha256.or_else(|| body.app_properties.remove(SHA_PROPERTY)); + Ok(RemoteFile { + sha256_checksum: body.sha256_checksum.take(), + web_view_link: body.web_view_link.take(), + synced_sha256, + id: required_id(body)?, + }) +} + +pub struct DriveRestAdapter { + http: Client, + api: String, + upload: String, + /// `cache/drive-imports`: one fresh folder per download. + imports: PathBuf, +} + +impl DriveRestAdapter { + pub fn new(app: &AppHandle) -> Result { + let imports = AppPaths::resolve(app)?.cache.join("drive-imports"); + Self::with_tuning(&Tuning::production(), imports) + } + + pub(super) fn with_tuning(tuning: &Tuning, imports: PathBuf) -> Result { + // No overall timeout on the client: a download takes as long as the + // file needs. Every other request sets its own. + let http = Client::builder() + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|_| unavailable())?; + Ok(Self { + http, + api: tuning.drive_api_url.clone(), + upload: tuning.drive_upload_url.clone(), + imports, + }) + } + + /// Remove every leftover import; called once at startup. + pub fn clear_imports(&self) -> std::io::Result<()> { + match std::fs::remove_dir_all(&self.imports) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } + } + + fn url(base: &str, path: &str, query: &[(&str, &str)]) -> Result { + let mut url = Url::parse(&format!("{base}{path}")).map_err(|_| unreadable())?; + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query); + } + Ok(url) + } + + /// Send with the request timeout and return the body of a success. + async fn call(&self, request: RequestBuilder, access_token: &str) -> Result, AppError> { + let response = request + .bearer_auth(access_token) + .timeout(REQUEST_TIMEOUT) + .send() + .await + .map_err(|_| unavailable())?; + let status = response.status(); + let bytes = response.bytes().await.map_err(|_| unavailable())?; + if status.is_success() { + Ok(bytes.to_vec()) + } else { + Err(api_error(status, &bytes)) + } + } + + async fn list( + &self, + access_token: &str, + q: &str, + fields: &str, + ) -> Result, AppError> { + let url = Self::url( + &self.api, + "/files", + &[ + ("q", q), + ("fields", fields), + ("spaces", "drive"), + ("pageSize", "10"), + ], + )?; + let bytes = self.call(self.http.get(url), access_token).await?; + Ok(parse::(&bytes)?.files) + } + + async fn create_folder( + &self, + access_token: &str, + metadata: &Value, + ) -> Result { + let url = Self::url(&self.api, "/files", &[("fields", "id,webViewLink")])?; + let request = self + .http + .post(url) + .header(CONTENT_TYPE, "application/json") + .body(metadata.to_string()); + parse(&self.call(request, access_token).await?) + } + + /// Whether `id` still names a folder Galpi may use. + async fn usable_folder(&self, access_token: &str, id: &str) -> Result { + let Ok(id) = DriveFileId::parse(id) else { + return Ok(false); + }; + let url = Self::url( + &self.api, + &format!("/files/{}", id.as_str()), + &[("fields", "id,trashed")], + )?; + match self.call(self.http.get(url), access_token).await { + Ok(bytes) => Ok(!parse::(&bytes)?.trashed), + Err(error) if error.code == "DRIVE_NOT_FOUND" => Ok(false), + Err(error) => Err(error), + } + } + + async fn send_file( + &self, + request: RequestBuilder, + access_token: &str, + metadata: &Value, + kind: ArtifactKind, + upload: &LocalUpload, + ) -> Result { + let (content_type, body) = multipart(metadata, mime_type(kind), &upload.content); + let request = request.header(CONTENT_TYPE, content_type).body(body); + let bytes = self.call(request, access_token).await?; + remote_file(parse(&bytes)?, Some(upload.sha256.clone())) + } + + /// Stream `id` into `folder/name` through a `.part` file. + async fn stream( + &self, + access_token: &str, + id: &DriveFileId, + folder: &Path, + name: &str, + modified: Option, + cancel: &mut oneshot::Receiver<()>, + ) -> Result { + let url = Self::url( + &self.api, + &format!("/files/{}", id.as_str()), + &[("alt", "media")], + )?; + let request = self.http.get(url).bearer_auth(access_token).send(); + let mut response: Response = tokio::select! { + biased; + _ = &mut *cancel => return Err(cancelled()), + sent = timeout(CHUNK_TIMEOUT, request) => { + sent.map_err(|_| unavailable())?.map_err(|_| unavailable())? + } + }; + let status = response.status(); + if !status.is_success() { + let body = response.bytes().await.unwrap_or_default(); + let error = api_error(status, &body); + return Err(match error.code.as_str() { + "DRIVE_TOKEN_REJECTED" | "DRIVE_UNAVAILABLE" => error, + _ => download_failed(), + }); + } + let part = folder.join(format!("{name}.part")); + let mut file = tokio::fs::File::create(&part) + .await + .map_err(|error| AppError::io("내려받을 파일을 만들지 못했습니다", &error))?; + loop { + let chunk = tokio::select! { + biased; + _ = &mut *cancel => return Err(cancelled()), + chunk = timeout(CHUNK_TIMEOUT, response.chunk()) => chunk, + }; + match chunk { + Ok(Ok(Some(bytes))) => file + .write_all(&bytes) + .await + .map_err(|error| AppError::io("내려받은 내용을 저장하지 못했습니다", &error))?, + Ok(Ok(None)) => break, + Ok(Err(_)) | Err(_) => return Err(unavailable()), + } + } + file.flush() + .await + .map_err(|error| AppError::io("내려받은 내용을 저장하지 못했습니다", &error))?; + let file = file.into_std().await; + // Refinement dates the minutes from the recording's modification + // time, so the copy keeps the Drive file's rather than today's. + if let Some(modified) = modified { + file.set_modified(modified) + .map_err(|error| AppError::io("내려받은 파일 시각을 맞추지 못했습니다", &error))?; + } + drop(file); + let destination = folder.join(name); + tokio::fs::rename(&part, &destination) + .await + .map_err(|error| AppError::io("내려받은 파일을 저장하지 못했습니다", &error))?; + canonical(&destination) + .await + .map_err(|error| AppError::io("내려받은 파일을 확인하지 못했습니다", &error)) + } +} + +#[async_trait] +impl DriveFilesPort for DriveRestAdapter { + async fn whoami(&self, access_token: &str) -> Result { + let url = Self::url( + &self.api, + "/about", + &[("fields", "user(emailAddress,permissionId)")], + )?; + let about: AboutBody = parse(&self.call(self.http.get(url), access_token).await?)?; + Ok(DriveAccount { + email: about.user.email_address, + permission_id: about.user.permission_id, + }) + } + + async fn ensure_root_folder( + &self, + access_token: &str, + known: Option<&str>, + ) -> Result { + if let Some(known) = known + && self.usable_folder(access_token, known).await? + { + return Ok(known.to_owned()); + } + let q = format!( + "mimeType='{FOLDER_MIME}' and trashed=false and {}", + has_property(ROOT_PROPERTY, "1") + ); + if let Some(found) = self + .list(access_token, &q, "files(id)") + .await? + .into_iter() + .next() + { + return required_id(found); + } + let created = self + .create_folder( + access_token, + &json!({ + "name": ROOT_FOLDER_NAME, + "mimeType": FOLDER_MIME, + "appProperties": {ROOT_PROPERTY: "1"}, + }), + ) + .await?; + required_id(created) + } + + async fn ensure_meeting_folder( + &self, + access_token: &str, + root: &str, + key: &MeetingKey, + name: &str, + ) -> Result { + let q = format!( + "mimeType='{FOLDER_MIME}' and '{}' in parents and trashed=false and {}", + quote(root), + has_property(MEETING_PROPERTY, key.as_str()) + ); + let found = self + .list(access_token, &q, "files(id,webViewLink)") + .await? + .into_iter() + .next(); + let mut folder = if let Some(folder) = found { + folder + } else { + self.create_folder( + access_token, + &json!({ + "name": name, + "mimeType": FOLDER_MIME, + "parents": [root], + "appProperties": {MEETING_PROPERTY: key.as_str()}, + }), + ) + .await? + }; + let link = folder.web_view_link.take(); + Ok(RemoteFolder { + id: required_id(folder)?, + link, + }) + } + + async fn find_artifact( + &self, + access_token: &str, + folder: &str, + kind: ArtifactKind, + ) -> Result, AppError> { + let q = format!( + "'{}' in parents and trashed=false and {}", + quote(folder), + has_property(KIND_PROPERTY, kind_name(kind)) + ); + self.list( + access_token, + &q, + "files(id,sha256Checksum,appProperties,webViewLink)", + ) + .await? + .into_iter() + .next() + .map(|file| remote_file(file, None)) + .transpose() + } + + async fn read_local(&self, path: &Path) -> Result { + let metadata = tokio::fs::metadata(path) + .await + .map_err(|error| AppError::io("올릴 결과 파일을 확인하지 못했습니다", &error))?; + if metadata.len() > MAX_UPLOAD_BYTES { + return Err(AppError::new( + "DRIVE_UPLOAD_TOO_LARGE", + "5MB가 넘는 파일은 Drive에 올리지 않습니다.", + )); + } + let content = tokio::fs::read(path) + .await + .map_err(|error| AppError::io("올릴 결과 파일을 읽지 못했습니다", &error))?; + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .ok_or_else(|| AppError::new("DRIVE_REQUEST_FAILED", "결과 파일 이름이 없습니다."))?; + Ok(LocalUpload { + name, + sha256: sha256_hex(&content), + content, + }) + } + + async fn create_file( + &self, + access_token: &str, + folder: &str, + key: &MeetingKey, + kind: ArtifactKind, + upload: &LocalUpload, + ) -> Result { + let url = Self::url( + &self.upload, + "/files", + &[ + ("uploadType", "multipart"), + ("fields", "id,webViewLink,sha256Checksum"), + ], + )?; + let metadata = json!({ + "name": upload.name, + "parents": [folder], + "mimeType": mime_type(kind), + "appProperties": file_properties(key, kind, &upload.sha256), + }); + self.send_file(self.http.post(url), access_token, &metadata, kind, upload) + .await + } + + async fn update_file( + &self, + access_token: &str, + file_id: &str, + key: &MeetingKey, + kind: ArtifactKind, + upload: &LocalUpload, + ) -> Result { + let id = DriveFileId::parse(file_id).map_err(|_| unreadable())?; + let url = Self::url( + &self.upload, + &format!("/files/{}", id.as_str()), + &[ + ("uploadType", "multipart"), + ("fields", "id,webViewLink,sha256Checksum"), + ], + )?; + let metadata = json!({"appProperties": file_properties(key, kind, &upload.sha256)}); + self.send_file(self.http.patch(url), access_token, &metadata, kind, upload) + .await + } + + async fn file_metadata( + &self, + access_token: &str, + id: &DriveFileId, + ) -> Result { + let url = Self::url( + &self.api, + &format!("/files/{}", id.as_str()), + &[( + "fields", + "name,mimeType,capabilities(canDownload),modifiedTime", + )], + )?; + let body: FileBody = parse(&self.call(self.http.get(url), access_token).await?)?; + Ok(PickedFile { + name: body.name.unwrap_or_default(), + mime_type: body.mime_type.unwrap_or_default(), + can_download: body + .capabilities + .is_some_and(|capabilities| capabilities.can_download), + modified_time: body.modified_time, + }) + } + + async fn download( + &self, + access_token: &str, + id: &DriveFileId, + file: &PickedFile, + cancel: &mut oneshot::Receiver<()>, + ) -> Result { + let folder = self.imports.join(Uuid::new_v4().to_string()); + tokio::fs::create_dir_all(&folder) + .await + .map_err(|error| AppError::io("내려받을 폴더를 만들지 못했습니다", &error))?; + let modified = file + .modified_time + .as_deref() + .and_then(|time| chrono::DateTime::parse_from_rfc3339(time).ok()) + .map(SystemTime::from); + let result = self + .stream( + access_token, + id, + &folder, + &local_name(&file.name), + modified, + cancel, + ) + .await; + if result.is_err() { + // Nothing half-downloaded stays behind; startup clears any rest. + let _removed = tokio::fs::remove_dir_all(&folder).await; + } + result + } +} + +#[cfg(test)] +mod tests { + use super::{local_name, multipart, quote, sha256_hex}; + use serde_json::json; + + #[test] + fn query_values_escape_quotes_and_backslashes() { + assert_eq!(quote(r"it's a\b"), r"it\'s a\\b"); + } + + #[test] + fn checksums_are_lowercase_hex_like_drive_reports_them() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn downloaded_names_are_sanitized_and_keep_the_extension() { + assert_eq!(local_name("주간 회의.m4a"), "주간 회의.m4a"); + assert_eq!(local_name("../../etc/passwd"), "passwd"); + assert_eq!(local_name("a:b?.WAV"), "a-b.WAV"); + assert_eq!(local_name(".m4a"), "m4a"); + assert_eq!(local_name("녹음.m4a/.."), "meeting"); + // Windows path syntax must not change the result on any OS. + assert_eq!(local_name(r"C:\x\y.m4a"), "y.m4a"); + assert_eq!(local_name(r"..\..\boot.ini"), "boot.ini"); + assert_eq!(local_name("C:"), "C"); + assert_eq!(local_name("c:.wav"), "c.wav"); + } + + #[test] + fn multipart_bodies_carry_metadata_then_content() { + let (content_type, body) = multipart(&json!({"name": "a.txt"}), "text/plain", b"hello"); + let boundary = content_type + .strip_prefix("multipart/related; boundary=") + .unwrap_or_default(); + let body = String::from_utf8_lossy(&body); + + assert_ne!(boundary, ""); + assert!(body.starts_with(&format!("--{boundary}\r\nContent-Type: application/json"))); + assert!(body.contains("{\"name\":\"a.txt\"}")); + assert!(body.contains("Content-Type: text/plain\r\n\r\nhello\r\n")); + assert!(body.ends_with(&format!("--{boundary}--"))); + } +} diff --git a/src-tauri/src/adapters/outbound/drive/tests.rs b/src-tauri/src/adapters/outbound/drive/tests.rs new file mode 100644 index 0000000..2bfa763 --- /dev/null +++ b/src-tauri/src/adapters/outbound/drive/tests.rs @@ -0,0 +1,924 @@ +use super::{DriveOAuthAdapter, DriveRestAdapter, Tuning}; +use crate::adapters::outbound::browser_sign_in::fake_server::{FakeServer, Handler, Recorded}; +use crate::adapters::outbound::browser_sign_in::pkce::code_challenge; +use crate::adapters::outbound::browser_sign_in::{BrowserOpener, SignInCodes}; +use crate::application::error::AppError; +use crate::application::ports::{ + ClockPort, DriveAuthPort, DriveAuthorize, DriveCallback, DriveFilesPort, LocalUpload, + PickedFile, +}; +use crate::domain::artifact::ArtifactKind; +use crate::domain::drive::{DriveFileId, DriveTokenFailure, DriveTokens, MeetingKey}; +use reqwest::Url; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, UNIX_EPOCH}; +use tokio::sync::oneshot; +use uuid::Uuid; + +const NOW: u64 = 1_000_000; + +/// What the browser "user" sends back, given the `state` of the request. +type Behavior = Box String + Send + Sync>; + +struct FakeBrowser { + behavior: Behavior, + urls: Mutex>, +} + +impl BrowserOpener for FakeBrowser { + fn open(&self, url: &str, _codes: SignInCodes) -> Result<(), AppError> { + let parsed = Url::parse(url).map_err(|_| AppError::new("TEST", "bad url"))?; + let param = |name: &str| query(&parsed, name).unwrap_or_default(); + if let Ok(mut urls) = self.urls.lock() { + urls.push(url.to_owned()); + } + let target = format!( + "{}?{}", + param("redirect_uri"), + (self.behavior)(¶m("state")) + ); + tokio::spawn(async move { + let _response = reqwest::get(target).await; + }); + Ok(()) + } +} + +struct FixedClock; + +impl ClockPort for FixedClock { + fn now_unix(&self) -> u64 { + NOW + } +} + +fn query(url: &Url, name: &str) -> Option { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) +} + +fn json_body(request: &Recorded) -> Value { + serde_json::from_str(&request.body).unwrap_or(Value::Null) +} + +fn tuning(base: &str) -> Tuning { + Tuning { + gateway_url: base.to_owned(), + google_auth_url: format!("{base}/o/oauth2/v2/auth"), + google_revoke_url: format!("{base}/revoke"), + drive_api_url: format!("{base}/drive/v3"), + drive_upload_url: format!("{base}/upload/drive/v3"), + preferred_port: 0, + sign_in_timeout: Duration::from_secs(30), + } +} + +fn not_found() -> (u16, String) { + ( + 404, + json!({"error": {"code": 404, "errors": [{"reason": "notFound"}]}}).to_string(), + ) +} + +struct AuthHarness { + server: FakeServer, + browser: Arc, + adapter: DriveOAuthAdapter, +} + +impl AuthHarness { + async fn start(behavior: Behavior, handler: Handler) -> Result { + let server = FakeServer::start(handler) + .await + .map_err(|error| AppError::io("server", &error))?; + let browser = Arc::new(FakeBrowser { + behavior, + urls: Mutex::new(Vec::new()), + }); + let adapter = DriveOAuthAdapter::with_tuning( + tuning(&server.base), + Arc::clone(&browser) as Arc, + Arc::new(FixedClock), + )?; + Ok(Self { + server, + browser, + adapter, + }) + } + + fn opened_url(&self) -> Result { + let urls = self + .browser + .urls + .lock() + .map_err(|_| AppError::new("TEST", "urls lock"))?; + let url = urls + .first() + .ok_or_else(|| AppError::new("TEST", "no browser was opened"))?; + Url::parse(url).map_err(|_| AppError::new("TEST", "bad url")) + } + + async fn authorize(&self, request: &DriveAuthorize) -> Result { + let (_cancel, mut receiver) = oneshot::channel(); + self.adapter.authorize(request, &mut receiver).await + } +} + +fn answer(extra: &'static str) -> Behavior { + Box::new(move |state| format!("state={state}{extra}")) +} + +fn no_server() -> Handler { + Arc::new(|_request| (404, String::new())) +} + +const CONNECT: DriveAuthorize = DriveAuthorize { + picker: false, + login_hint: None, +}; + +fn callback() -> DriveCallback { + DriveCallback { + code: "galpi-test-code".to_owned(), + verifier: "galpi-test-verifier".to_owned(), + redirect_uri: "http://127.0.0.1:1455/auth/callback".to_owned(), + picked: Vec::new(), + } +} + +fn token_answer(refresh: Option<&str>) -> String { + let mut body = json!({ + "access_token": "galpi-test-access", + "expires_in": 3599, + "scope": "https://www.googleapis.com/auth/drive.file", + "token_type": "Bearer", + }); + if let Some(refresh) = refresh { + body["refresh_token"] = json!(refresh); + } + body.to_string() +} + +fn fixed(status: u16, body: String) -> Handler { + Arc::new(move |_request| (status, body.clone())) +} + +#[tokio::test] +async fn connecting_asks_google_for_drive_file_with_pkce_and_offline_access() -> Result<(), AppError> +{ + // Given + let harness = AuthHarness::start(answer("&code=galpi-test-code"), no_server()).await?; + + // When + let callback = harness.authorize(&CONNECT).await?; + + // Then + let url = harness.opened_url()?; + assert_eq!(url.path(), "/o/oauth2/v2/auth"); + let param = |name: &str| query(&url, name); + assert_eq!(param("client_id").as_deref(), Some(super::CLIENT_ID)); + assert_eq!(param("scope").as_deref(), Some(super::SCOPE)); + assert_eq!(param("response_type").as_deref(), Some("code")); + assert_eq!(param("access_type").as_deref(), Some("offline")); + assert_eq!(param("prompt").as_deref(), Some("consent")); + assert_eq!(param("code_challenge_method").as_deref(), Some("S256")); + assert_eq!(param("state").map(|state| state.len()), Some(43)); + assert_eq!(param("redirect_uri"), Some(callback.redirect_uri.clone())); + assert_eq!( + param("code_challenge"), + Some(code_challenge(&callback.verifier)) + ); + for absent in ["include_granted_scopes", "trigger_onepick", "login_hint"] { + assert_eq!(param(absent), None, "{absent}"); + } + assert_eq!(callback.code, "galpi-test-code"); + assert_eq!(callback.picked, Vec::::new()); + Ok(()) +} + +#[tokio::test] +async fn the_picker_adds_onepick_audio_types_and_the_account_hint() -> Result<(), AppError> { + // Given + let harness = AuthHarness::start( + answer("&code=galpi-test-code&picked_file_ids=file-1"), + no_server(), + ) + .await?; + + // When + let callback = harness + .authorize(&DriveAuthorize { + picker: true, + login_hint: Some("user@example.com".to_owned()), + }) + .await?; + + // Then + let url = harness.opened_url()?; + assert_eq!(query(&url, "trigger_onepick").as_deref(), Some("true")); + assert_eq!(query(&url, "prompt").as_deref(), Some("consent")); + assert_eq!( + query(&url, "login_hint").as_deref(), + Some("user@example.com") + ); + let mimetypes = query(&url, "mimetypes").unwrap_or_default(); + assert!(mimetypes.contains("audio/x-m4a") && mimetypes.contains("video/mp4")); + assert_eq!( + callback + .picked + .iter() + .map(DriveFileId::as_str) + .collect::>(), + ["file-1"] + ); + Ok(()) +} + +#[tokio::test] +async fn callbacks_that_do_not_answer_this_request_are_refused() -> Result<(), AppError> { + let cases: [(Behavior, &str); 5] = [ + ( + Box::new(|_state| "state=someone-else&code=galpi-test-code".to_owned()), + "DRIVE_STATE_MISMATCH", + ), + (answer("&error=access_denied"), "CANCELLED"), + (answer("&error=server_error"), "DRIVE_CONNECT_FAILED"), + (answer(""), "DRIVE_CONNECT_FAILED"), + ( + answer("&code=galpi-test-code&picked_file_ids=a/b"), + "DRIVE_PICK_INVALID", + ), + ]; + for (behavior, code) in cases { + let harness = AuthHarness::start(behavior, no_server()).await?; + let error = harness + .authorize(&CONNECT) + .await + .err() + .ok_or_else(|| AppError::new("TEST", "expected a refusal"))?; + assert_eq!(error.code, code); + assert!(!error.message.contains("galpi-test-code")); + } + Ok(()) +} + +#[tokio::test] +async fn the_gateway_exchanges_the_code_on_the_bearers_behalf() -> Result<(), AppError> { + // Given + let harness = AuthHarness::start( + answer(""), + fixed(200, token_answer(Some("galpi-test-refresh"))), + ) + .await?; + + // When + let tokens = harness + .adapter + .exchange("galpi-test-gateway", &callback()) + .await + .map_err(|failure| AppError::new("TEST", format!("{failure:?}")))?; + + // Then: only the tokens are kept + assert_eq!( + tokens, + DriveTokens { + access_token: "galpi-test-access".to_owned(), + refresh_token: "galpi-test-refresh".to_owned(), + expires_at: NOW + 3599, + } + ); + let requests = harness.server.requests(); + let exchange = requests + .first() + .ok_or_else(|| AppError::new("TEST", "no exchange"))?; + assert_eq!(exchange.method, "POST"); + assert_eq!(exchange.route(), "/drive/native/token"); + assert_eq!( + exchange.authorization.as_deref(), + Some("Bearer galpi-test-gateway") + ); + assert_eq!(exchange.content_type.as_deref(), Some("application/json")); + let body = json_body(exchange); + assert_eq!(body["grant_type"], "authorization_code"); + assert_eq!(body["code"], "galpi-test-code"); + assert_eq!(body["code_verifier"], "galpi-test-verifier"); + assert_eq!(body["redirect_uri"], "http://127.0.0.1:1455/auth/callback"); + assert_eq!(body.as_object().map(serde_json::Map::len), Some(4)); + Ok(()) +} + +#[tokio::test] +async fn token_failures_map_to_what_the_caller_can_do() -> Result<(), AppError> { + let cases = [ + ( + 400, + json!({"error": "invalid_grant"}).to_string(), + DriveTokenFailure::Rejected, + ), + ( + 400, + json!({"error": "invalid_request"}).to_string(), + DriveTokenFailure::Unavailable, + ), + ( + 401, + json!({"error": "unauthorized"}).to_string(), + DriveTokenFailure::AuthRequired, + ), + ( + 503, + json!({"error": "drive_unavailable"}).to_string(), + DriveTokenFailure::Unavailable, + ), + (200, "not json".to_owned(), DriveTokenFailure::Unavailable), + (200, token_answer(None), DriveTokenFailure::Unavailable), + ]; + for (status, body, expected) in cases { + let harness = AuthHarness::start(answer(""), fixed(status, body)).await?; + assert_eq!( + harness + .adapter + .exchange("galpi-test-gateway", &callback()) + .await + .err(), + Some(expected), + "status {status}" + ); + } + + // And: a gateway that cannot be reached + let unreachable = DriveOAuthAdapter::with_tuning( + tuning("http://127.0.0.1:9"), + Arc::new(FakeBrowser { + behavior: answer(""), + urls: Mutex::new(Vec::new()), + }), + Arc::new(FixedClock), + )?; + assert_eq!( + unreachable + .refresh("galpi-test-gateway", "galpi-test-refresh") + .await + .err(), + Some(DriveTokenFailure::Unavailable) + ); + Ok(()) +} + +#[tokio::test] +async fn a_renewal_without_a_rotated_refresh_token_keeps_the_old_one() -> Result<(), AppError> { + // Given + let harness = AuthHarness::start(answer(""), fixed(200, token_answer(None))).await?; + + // When + let tokens = harness + .adapter + .refresh("galpi-test-gateway", "galpi-test-refresh-old") + .await + .map_err(|failure| AppError::new("TEST", format!("{failure:?}")))?; + + // Then + assert_eq!(tokens.refresh_token, "galpi-test-refresh-old"); + let requests = harness.server.requests(); + let body = requests.first().map_or(Value::Null, json_body); + assert_eq!( + body, + json!({"grant_type": "refresh_token", "refresh_token": "galpi-test-refresh-old"}) + ); + Ok(()) +} + +#[tokio::test] +async fn revocation_posts_the_token_to_google_and_tolerates_failure() -> Result<(), AppError> { + // Given + let harness = AuthHarness::start(answer(""), fixed(500, String::new())).await?; + + // When + harness.adapter.revoke("galpi-test-refresh").await; + + // Then + let requests = harness.server.requests(); + let revoke = requests + .first() + .ok_or_else(|| AppError::new("TEST", "no revocation"))?; + assert_eq!(revoke.route(), "/revoke"); + assert_eq!(revoke.field("token").as_deref(), Some("galpi-test-refresh")); + assert_eq!(revoke.authorization, None); + Ok(()) +} + +struct RestHarness { + server: FakeServer, + adapter: DriveRestAdapter, + imports: PathBuf, +} + +impl RestHarness { + async fn start(handler: Handler) -> Result { + let server = FakeServer::start(handler) + .await + .map_err(|error| AppError::io("server", &error))?; + let imports = std::env::temp_dir().join(format!("galpi-drive-imports-{}", Uuid::now_v7())); + let adapter = DriveRestAdapter::with_tuning(&tuning(&server.base), imports.clone())?; + Ok(Self { + server, + adapter, + imports, + }) + } + + fn requests_to(&self, method: &str, route: &str) -> Vec { + self.server + .requests() + .into_iter() + .filter(|request| request.method == method && request.route() == route) + .collect() + } + + fn import_folders(&self) -> usize { + std::fs::read_dir(&self.imports).map_or(0, Iterator::count) + } +} + +impl Drop for RestHarness { + fn drop(&mut self) { + let _removed = std::fs::remove_dir_all(&self.imports); + } +} + +const TOKEN: &str = "galpi-test-drive-access"; + +fn upload(content: &str) -> LocalUpload { + LocalUpload { + name: "meeting_화자별.txt".to_owned(), + content: content.as_bytes().to_vec(), + sha256: "galpi-sha".to_owned(), + } +} + +#[tokio::test] +async fn whoami_reads_the_account_email_and_permission_id() -> Result<(), AppError> { + let harness = RestHarness::start(fixed( + 200, + json!({"user": {"emailAddress": "user@example.com", "permissionId": "perm-1"}}).to_string(), + )) + .await?; + + let account = harness.adapter.whoami(TOKEN).await?; + + assert_eq!(account.email, "user@example.com"); + assert_eq!(account.permission_id, "perm-1"); + let about = harness.requests_to("GET", "/drive/v3/about"); + assert_eq!( + about + .first() + .and_then(|request| request.authorization.clone()), + Some(format!("Bearer {TOKEN}")) + ); + Ok(()) +} + +#[tokio::test] +async fn the_root_folder_is_reused_found_again_or_created() -> Result<(), AppError> { + // Given: the remembered folder still exists + let reuse = RestHarness::start(fixed( + 200, + json!({"id": "root-1", "trashed": false}).to_string(), + )) + .await?; + assert_eq!( + reuse + .adapter + .ensure_root_folder(TOKEN, Some("root-1")) + .await?, + "root-1" + ); + assert!(reuse.requests_to("POST", "/drive/v3/files").is_empty()); + + // Given: the remembered folder is gone, but a marked one exists + let found = RestHarness::start(Arc::new(|request| match request.route() { + "/drive/v3/files/root-1" => not_found(), + "/drive/v3/files" => (200, json!({"files": [{"id": "root-2"}]}).to_string()), + _ => (500, String::new()), + })) + .await?; + assert_eq!( + found + .adapter + .ensure_root_folder(TOKEN, Some("root-1")) + .await?, + "root-2" + ); + let search = found.requests_to("GET", "/drive/v3/files"); + let q = search + .first() + .and_then(|request| request.query("q")) + .unwrap_or_default(); + assert!( + q.contains("appProperties has { key='galpiRoot' and value='1' }"), + "{q}" + ); + assert!(q.contains("trashed=false"), "{q}"); + + // Given: nothing is on Drive yet + let created = RestHarness::start(Arc::new(|request| match request.method.as_str() { + "GET" => (200, json!({"files": []}).to_string()), + _ => (200, json!({"id": "root-3"}).to_string()), + })) + .await?; + assert_eq!( + created.adapter.ensure_root_folder(TOKEN, None).await?, + "root-3" + ); + let creation = created.requests_to("POST", "/drive/v3/files"); + let body = creation.first().map_or(Value::Null, json_body); + assert_eq!(body["name"], "Galpi"); + assert_eq!(body["mimeType"], "application/vnd.google-apps.folder"); + assert_eq!(body["appProperties"]["galpiRoot"], "1"); + Ok(()) +} + +#[tokio::test] +async fn a_trashed_root_folder_is_replaced() -> Result<(), AppError> { + let harness = RestHarness::start(Arc::new(|request| match request.route() { + "/drive/v3/files/root-1" => (200, json!({"id": "root-1", "trashed": true}).to_string()), + _ if request.method == "GET" => (200, json!({"files": []}).to_string()), + _ => (200, json!({"id": "root-2"}).to_string()), + })) + .await?; + + assert_eq!( + harness + .adapter + .ensure_root_folder(TOKEN, Some("root-1")) + .await?, + "root-2" + ); + Ok(()) +} + +#[tokio::test] +async fn meeting_folders_are_found_by_key_or_created_under_the_root() -> Result<(), AppError> { + let key = + MeetingKey::drive(&DriveFileId::parse("file-1").map_err(|_| AppError::new("TEST", "id"))?); + + // Given: an existing meeting folder + let found = RestHarness::start(fixed( + 200, + json!({"files": [{"id": "meeting-1", "webViewLink": "https://drive.google.com/m1"}]}) + .to_string(), + )) + .await?; + let folder = found + .adapter + .ensure_meeting_folder(TOKEN, "root-1", &key, "주간 회의") + .await?; + assert_eq!(folder.id, "meeting-1"); + assert_eq!(folder.link.as_deref(), Some("https://drive.google.com/m1")); + let q = found + .requests_to("GET", "/drive/v3/files") + .first() + .and_then(|request| request.query("q")) + .unwrap_or_default(); + assert!(q.contains("'root-1' in parents"), "{q}"); + assert!( + q.contains("key='galpiMeeting' and value='drive:file-1'"), + "{q}" + ); + + // Given: none yet + let created = RestHarness::start(Arc::new(|request| match request.method.as_str() { + "GET" => (200, json!({"files": []}).to_string()), + _ => ( + 200, + json!({"id": "meeting-2", "webViewLink": "https://drive.google.com/m2"}).to_string(), + ), + })) + .await?; + let folder = created + .adapter + .ensure_meeting_folder(TOKEN, "root-1", &key, "주간 회의") + .await?; + assert_eq!(folder.id, "meeting-2"); + let body = created + .requests_to("POST", "/drive/v3/files") + .first() + .map_or(Value::Null, json_body); + assert_eq!(body["name"], "주간 회의"); + assert_eq!(body["parents"], json!(["root-1"])); + assert_eq!(body["appProperties"]["galpiMeeting"], "drive:file-1"); + Ok(()) +} + +#[tokio::test] +async fn result_files_are_found_by_kind_with_their_checksums() -> Result<(), AppError> { + let harness = RestHarness::start(fixed( + 200, + json!({"files": [{ + "id": "txt-1", + "sha256Checksum": "abc", + "appProperties": {"galpiSha256": "def", "galpiKind": "speaker_text"}, + "webViewLink": "https://drive.google.com/t1", + }]}) + .to_string(), + )) + .await?; + + let found = harness + .adapter + .find_artifact(TOKEN, "meeting-1", ArtifactKind::SpeakerText) + .await? + .ok_or_else(|| AppError::new("TEST", "not found"))?; + + assert_eq!(found.id, "txt-1"); + assert_eq!(found.sha256_checksum.as_deref(), Some("abc")); + assert_eq!(found.synced_sha256.as_deref(), Some("def")); + let q = harness + .requests_to("GET", "/drive/v3/files") + .first() + .and_then(|request| request.query("q")) + .unwrap_or_default(); + assert!(q.contains("'meeting-1' in parents"), "{q}"); + assert!( + q.contains("key='galpiKind' and value='speaker_text'"), + "{q}" + ); + Ok(()) +} + +#[tokio::test] +async fn new_results_upload_as_one_multipart_request_with_galpi_properties() -> Result<(), AppError> +{ + // Given + let harness = RestHarness::start(fixed( + 200, + json!({"id": "txt-1", "webViewLink": "https://drive.google.com/t1", "sha256Checksum": "galpi-sha"}) + .to_string(), + )) + .await?; + let key = MeetingKey::local(Path::new("/tmp/meeting.m4a")); + + // When + let created = harness + .adapter + .create_file( + TOKEN, + "meeting-1", + &key, + ArtifactKind::SpeakerText, + &upload("본문"), + ) + .await?; + + // Then + assert_eq!(created.id, "txt-1"); + assert_eq!(created.synced_sha256.as_deref(), Some("galpi-sha")); + let requests = harness.requests_to("POST", "/upload/drive/v3/files"); + let request = requests + .first() + .ok_or_else(|| AppError::new("TEST", "no upload"))?; + assert_eq!(request.query("uploadType").as_deref(), Some("multipart")); + let content_type = request.content_type.clone().unwrap_or_default(); + let boundary = content_type + .strip_prefix("multipart/related; boundary=") + .unwrap_or_default(); + assert!(!boundary.is_empty(), "{content_type}"); + assert!(request.body.contains(&format!("--{boundary}"))); + assert!( + request + .body + .contains("Content-Type: text/plain\r\n\r\n본문") + ); + let metadata: Value = request + .body + .split_once("\r\n\r\n") + .and_then(|(_, rest)| rest.split_once("\r\n--")) + .and_then(|(part, _)| serde_json::from_str(part).ok()) + .unwrap_or(Value::Null); + assert_eq!(metadata["name"], "meeting_화자별.txt"); + assert_eq!(metadata["parents"], json!(["meeting-1"])); + assert_eq!(metadata["mimeType"], "text/plain"); + assert_eq!(metadata["appProperties"]["galpiMeeting"], key.as_str()); + assert_eq!(metadata["appProperties"]["galpiKind"], "speaker_text"); + assert_eq!(metadata["appProperties"]["galpiSha256"], "galpi-sha"); + Ok(()) +} + +#[tokio::test] +async fn updates_patch_the_same_file_id() -> Result<(), AppError> { + let harness = RestHarness::start(fixed(200, json!({"id": "txt-1"}).to_string())).await?; + let key = MeetingKey::local(Path::new("/tmp/meeting.m4a")); + + let updated = harness + .adapter + .update_file( + TOKEN, + "txt-1", + &key, + ArtifactKind::Minutes, + &upload("# 회의록"), + ) + .await?; + + assert_eq!(updated.id, "txt-1"); + let patches = harness.requests_to("PATCH", "/upload/drive/v3/files/txt-1"); + let request = patches + .first() + .ok_or_else(|| AppError::new("TEST", "no patch"))?; + assert!( + request + .body + .contains("Content-Type: text/markdown\r\n\r\n# 회의록") + ); + assert!(!request.body.contains("\"parents\"")); + Ok(()) +} + +#[tokio::test] +async fn drive_errors_map_to_stable_codes() -> Result<(), AppError> { + let quota = json!({"error": {"errors": [{"reason": "storageQuotaExceeded"}]}}).to_string(); + let rate = json!({"error": {"errors": [{"reason": "userRateLimitExceeded"}]}}).to_string(); + let cases = [ + (401, String::new(), "DRIVE_TOKEN_REJECTED"), + (403, quota, "DRIVE_STORAGE_FULL"), + (403, rate, "DRIVE_UNAVAILABLE"), + (403, String::new(), "DRIVE_REQUEST_FAILED"), + (404, String::new(), "DRIVE_NOT_FOUND"), + (429, String::new(), "DRIVE_UNAVAILABLE"), + (500, String::new(), "DRIVE_UNAVAILABLE"), + ]; + for (status, body, code) in cases { + let harness = RestHarness::start(fixed(status, body)).await?; + let error = harness + .adapter + .whoami(TOKEN) + .await + .err() + .ok_or_else(|| AppError::new("TEST", "expected a failure"))?; + assert_eq!(error.code, code, "status {status}"); + assert!(!error.message.contains(TOKEN)); + } + Ok(()) +} + +#[tokio::test] +async fn large_results_are_not_uploaded() -> Result<(), AppError> { + let harness = RestHarness::start(no_server()).await?; + let directory = std::env::temp_dir().join(format!("galpi-drive-upload-{}", Uuid::now_v7())); + tokio::fs::create_dir_all(&directory) + .await + .map_err(|error| AppError::io("dir", &error))?; + let large = directory.join("large.txt"); + let small = directory.join("small.txt"); + tokio::fs::write(&large, vec![b'a'; 5 * 1024 * 1024 + 1]) + .await + .map_err(|error| AppError::io("write", &error))?; + tokio::fs::write(&small, b"abc") + .await + .map_err(|error| AppError::io("write", &error))?; + + let refused = harness + .adapter + .read_local(&large) + .await + .err() + .map(|error| error.code); + let read = harness.adapter.read_local(&small).await?; + + assert_eq!(refused.as_deref(), Some("DRIVE_UPLOAD_TOO_LARGE")); + assert_eq!(read.name, "small.txt"); + assert_eq!( + read.sha256, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let _removed = tokio::fs::remove_dir_all(directory).await; + Ok(()) +} + +fn picked(modified: &str) -> PickedFile { + PickedFile { + name: "주간 회의.m4a".to_owned(), + mime_type: "audio/x-m4a".to_owned(), + can_download: true, + modified_time: Some(modified.to_owned()), + } +} + +#[tokio::test] +async fn downloads_land_in_a_fresh_folder_with_the_drive_modification_time() -> Result<(), AppError> +{ + // Given + let harness = RestHarness::start(fixed(200, "galpi-test-audio".to_owned())).await?; + let id = DriveFileId::parse("file-1").map_err(|_| AppError::new("TEST", "id"))?; + let (_cancel, mut receiver) = oneshot::channel(); + + // When + let path = harness + .adapter + .download( + TOKEN, + &id, + &picked("2026-10-01T09:30:00.000Z"), + &mut receiver, + ) + .await?; + + // Then + assert_eq!( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .as_deref(), + Some("주간 회의.m4a") + ); + assert_eq!( + tokio::fs::read_to_string(&path) + .await + .map_err(|error| AppError::io("read", &error))?, + "galpi-test-audio" + ); + let modified = tokio::fs::metadata(&path) + .await + .and_then(|metadata| metadata.modified()) + .map_err(|error| AppError::io("metadata", &error))?; + assert_eq!( + modified + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .ok(), + Some(1_790_847_000) + ); + let folder = path.parent().map(Path::to_path_buf).unwrap_or_default(); + let names: Vec = std::fs::read_dir(&folder) + .map_err(|error| AppError::io("list", &error))? + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, ["주간 회의.m4a"]); + let requests = harness.requests_to("GET", "/drive/v3/files/file-1"); + assert_eq!( + requests + .first() + .and_then(|request| request.query("alt")) + .as_deref(), + Some("media") + ); + Ok(()) +} + +#[tokio::test] +async fn a_cancelled_or_refused_download_leaves_no_folder_behind() -> Result<(), AppError> { + let id = DriveFileId::parse("file-1").map_err(|_| AppError::new("TEST", "id"))?; + + // Given: the user cancelled before the download began + let harness = RestHarness::start(fixed(200, "galpi-test-audio".to_owned())).await?; + let (cancel, mut receiver) = oneshot::channel(); + let _sent = cancel.send(()); + let error = harness + .adapter + .download(TOKEN, &id, &picked("2026-10-01T09:30:00Z"), &mut receiver) + .await + .err() + .map(|error| error.code); + assert_eq!(error.as_deref(), Some("CANCELLED")); + assert_eq!(harness.import_folders(), 0); + + // Given: Drive refuses the download + let refused = RestHarness::start(fixed( + 403, + json!({"error": {"errors": [{"reason": "cannotDownloadFile"}]}}).to_string(), + )) + .await?; + let (_cancel, mut receiver) = oneshot::channel(); + let error = refused + .adapter + .download(TOKEN, &id, &picked("2026-10-01T09:30:00Z"), &mut receiver) + .await + .err() + .map(|error| error.code); + assert_eq!(error.as_deref(), Some("DRIVE_DOWNLOAD_FAILED")); + assert_eq!(refused.import_folders(), 0); + Ok(()) +} + +#[tokio::test] +async fn startup_clears_leftover_imports() -> Result<(), AppError> { + let harness = RestHarness::start(no_server()).await?; + std::fs::create_dir_all(harness.imports.join("left-over")) + .map_err(|error| AppError::io("dir", &error))?; + + harness + .adapter + .clear_imports() + .map_err(|error| AppError::io("clear", &error))?; + + assert!(!harness.imports.exists()); + harness + .adapter + .clear_imports() + .map_err(|error| AppError::io("clear again", &error))?; + Ok(()) +} diff --git a/src-tauri/src/adapters/outbound/drive/tokens.rs b/src-tauri/src/adapters/outbound/drive/tokens.rs new file mode 100644 index 0000000..00e411b --- /dev/null +++ b/src-tauri/src/adapters/outbound/drive/tokens.rs @@ -0,0 +1,106 @@ +//! The code exchange and renewal at the auth-gateway's `/drive/native/token`. +//! +//! The gateway holds the desktop client secret and forwards to Google. Galpi's +//! own gateway session vouches for the request as a bearer token. + +use crate::application::ports::DriveCallback; +use crate::domain::drive::{DriveTokenFailure, DriveTokens}; +use reqwest::header::CONTENT_TYPE; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use serde_json::{Value, json}; + +#[derive(Deserialize)] +struct TokenBody { + access_token: String, + refresh_token: Option, + expires_in: u64, +} + +/// An OAuth error body: `{"error": ".."}`. +#[derive(Deserialize)] +struct ErrorBody { + error: String, +} + +async fn post( + http: &Client, + endpoint: &str, + bearer: &str, + body: &Value, +) -> Result { + let response = http + .post(endpoint) + .bearer_auth(bearer) + .header(CONTENT_TYPE, "application/json") + .body(body.to_string()) + .send() + .await + .map_err(|_| DriveTokenFailure::Unavailable)?; + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|_| DriveTokenFailure::Unavailable)?; + if status.is_success() { + return serde_json::from_slice(&bytes).map_err(|_| DriveTokenFailure::Unavailable); + } + if status == StatusCode::UNAUTHORIZED { + return Err(DriveTokenFailure::AuthRequired); + } + if status == StatusCode::BAD_REQUEST + && serde_json::from_slice::(&bytes) + .is_ok_and(|body| body.error == "invalid_grant") + { + return Err(DriveTokenFailure::Rejected); + } + Err(DriveTokenFailure::Unavailable) +} + +fn tokens_from(body: TokenBody, refresh_token: String, now: u64) -> DriveTokens { + DriveTokens { + access_token: body.access_token, + refresh_token, + expires_at: now.saturating_add(body.expires_in), + } +} + +pub(super) async fn exchange( + http: &Client, + endpoint: &str, + bearer: &str, + callback: &DriveCallback, + now: u64, +) -> Result { + let body = json!({ + "grant_type": "authorization_code", + "code": callback.code, + "code_verifier": callback.verifier, + "redirect_uri": callback.redirect_uri, + }); + let mut answer = post(http, endpoint, bearer, &body).await?; + // With offline access and `prompt=consent` Google returns a refresh token; + // an answer without one cannot keep the connection alive. + let refresh_token = answer + .refresh_token + .take() + .ok_or(DriveTokenFailure::Unavailable)?; + Ok(tokens_from(answer, refresh_token, now)) +} + +pub(super) async fn refresh( + http: &Client, + endpoint: &str, + bearer: &str, + refresh_token: &str, + now: u64, +) -> Result { + let body = json!({"grant_type": "refresh_token", "refresh_token": refresh_token}); + let mut answer = post(http, endpoint, bearer, &body).await?; + // Google rotates the refresh token only sometimes; otherwise the old one stays. + let refresh_token = answer + .refresh_token + .take() + .unwrap_or_else(|| refresh_token.to_owned()); + Ok(tokens_from(answer, refresh_token, now)) +} diff --git a/src-tauri/src/adapters/outbound/gateway/mod.rs b/src-tauri/src/adapters/outbound/gateway/mod.rs index 1a8c6f5..a6848ad 100644 --- a/src-tauri/src/adapters/outbound/gateway/mod.rs +++ b/src-tauri/src/adapters/outbound/gateway/mod.rs @@ -37,7 +37,8 @@ fn failed(message: &str) -> AppError { } /// The gateway base URL: the production one, or in a debug build the override. -fn gateway_url() -> String { +/// The Drive adapter shares it, so one override points both at a local gateway. +pub(super) fn gateway_url() -> String { if cfg!(debug_assertions) && let Ok(url) = std::env::var(URL_OVERRIDE) && !url.trim().is_empty() diff --git a/src-tauri/src/adapters/outbound/import.rs b/src-tauri/src/adapters/outbound/import.rs index 8a1a7ed..5e0f1de 100644 --- a/src-tauri/src/adapters/outbound/import.rs +++ b/src-tauri/src/adapters/outbound/import.rs @@ -59,6 +59,7 @@ pub async fn import_transcript(input: &Path, output_root: &Path) -> Result Result String { - input - .file_stem() - .and_then(|name| name.to_str()) - .map(sanitize_name) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| "meeting".to_owned()) + sanitized_stem( + input + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or_default(), + ) +} + +/// `stem` made safe for a file name, or `meeting` when nothing usable remains. +pub fn sanitized_stem(stem: &str) -> String { + let name = sanitize_name(stem); + if name.is_empty() { + "meeting".to_owned() + } else { + name + } } /// Meeting folder rule: the folder is named after the recording, so a Galpi diff --git a/src-tauri/src/adapters/outbound/secrets.rs b/src-tauri/src/adapters/outbound/secrets.rs index b9eb2d9..2160b62 100644 --- a/src-tauri/src/adapters/outbound/secrets.rs +++ b/src-tauri/src/adapters/outbound/secrets.rs @@ -41,6 +41,8 @@ pub enum Secret { ChatGptTokens, /// The serialized auth-gateway session. GatewaySession, + /// The serialized Google Drive OAuth tokens. + DriveSession, } impl Secret { @@ -54,6 +56,7 @@ impl Secret { Self::AssistantApiKey => "assistant-api-key", Self::ChatGptTokens => "chatgpt-tokens", Self::GatewaySession => "gateway-session", + Self::DriveSession => "drive-session", } } } diff --git a/src-tauri/src/adapters/outbound/settings.rs b/src-tauri/src/adapters/outbound/settings.rs index 8023847..9e1503b 100644 --- a/src-tauri/src/adapters/outbound/settings.rs +++ b/src-tauri/src/adapters/outbound/settings.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use tauri::AppHandle; mod chatgpt; +mod drive; mod gateway; #[derive(Debug)] @@ -107,6 +108,7 @@ impl LocalSettingsStore { Secret::AssistantApiKey => settings.assistant_api_key.clone(), Secret::ChatGptTokens => settings.chatgpt_tokens.clone(), Secret::GatewaySession => settings.gateway_session.clone(), + Secret::DriveSession => settings.drive_session.clone(), }) }; let Some(value) = legacy else { @@ -175,6 +177,14 @@ impl LocalSettingsStore { } settings.gateway_session_stored = present; } + Secret::DriveSession => { + if let Some(value) = retained { + settings.drive_session = value; + } else { + settings.drive_session = None; + } + settings.drive_session_stored = present; + } }) .await } @@ -188,6 +198,7 @@ impl LocalSettingsStore { Secret::AssistantApiKey => settings.assistant_api_key_stored == present, Secret::ChatGptTokens => settings.chatgpt_tokens_stored == present, Secret::GatewaySession => settings.gateway_session_stored == present, + Secret::DriveSession => settings.drive_session_stored == present, }) }; if already { @@ -198,6 +209,7 @@ impl LocalSettingsStore { Secret::AssistantApiKey => settings.assistant_api_key_stored = present, Secret::ChatGptTokens => settings.chatgpt_tokens_stored = present, Secret::GatewaySession => settings.gateway_session_stored = present, + Secret::DriveSession => settings.drive_session_stored = present, }) .await } @@ -231,6 +243,10 @@ impl LocalSettingsStore { settings.gateway_session_stored, settings.gateway_session.is_some(), ), + Secret::DriveSession => ( + settings.drive_session_stored, + settings.drive_session.is_some(), + ), }) }; match recorded { @@ -298,6 +314,12 @@ struct LocalSettings { gateway_email: Option, gateway_session: Option, gateway_session_stored: bool, + drive_email: Option, + drive_permission_id: Option, + drive_folder_id: Option, + drive_onboarding_dismissed: bool, + drive_session: Option, + drive_session_stored: bool, } impl LocalSettings { @@ -327,6 +349,12 @@ impl LocalSettings { && self.gateway_email.is_none() && self.gateway_session.is_none() && !self.gateway_session_stored + && self.drive_email.is_none() + && self.drive_permission_id.is_none() + && self.drive_folder_id.is_none() + && !self.drive_onboarding_dismissed + && self.drive_session.is_none() + && !self.drive_session_stored } } diff --git a/src-tauri/src/adapters/outbound/settings/drive.rs b/src-tauri/src/adapters/outbound/settings/drive.rs new file mode 100644 index 0000000..6f26c6f --- /dev/null +++ b/src-tauri/src/adapters/outbound/settings/drive.rs @@ -0,0 +1,278 @@ +//! The Google Drive connection on top of the shared settings document. +//! +//! The account, the `Galpi` folder id, and the onboarding choice are plain +//! fields of `settings.json`. The token record is a secret and rides the same +//! `SecretStore` path as the other credentials. + +use super::super::secrets::Secret; +use super::LocalSettingsStore; +use crate::application::error::AppError; +use crate::application::ports::DriveSessionStore; +use crate::domain::drive::{DriveConnection, DriveStatus, DriveTokens}; +use async_trait::async_trait; + +impl LocalSettingsStore { + async fn store_drive_tokens(&self, tokens: &DriveTokens) -> Result<(), AppError> { + let record = serde_json::to_string(tokens).map_err(|error| { + AppError::new( + "SETTINGS_SERIALIZE_ERROR", + format!("Drive 연결 정보를 저장 형식으로 바꾸지 못했습니다: {error}"), + ) + })?; + self.store_secret(Secret::DriveSession, Some(&record)).await + } +} + +#[async_trait] +impl DriveSessionStore for LocalSettingsStore { + async fn load_status(&self) -> Result { + let state = self.load().await?; + let settings = state.as_ref(); + Ok( + match settings.and_then(|settings| settings.drive_email.clone()) { + Some(email) => DriveStatus::Connected { email }, + None => DriveStatus::Disconnected { + onboarding_dismissed: settings + .is_some_and(|settings| settings.drive_onboarding_dismissed), + }, + }, + ) + } + + async fn load_connection(&self) -> Result, AppError> { + let state = self.load().await?; + Ok(state.as_ref().and_then(|settings| { + Some(DriveConnection { + email: settings.drive_email.clone()?, + permission_id: settings.drive_permission_id.clone()?, + folder_id: settings.drive_folder_id.clone()?, + }) + })) + } + + /// A record that no longer parses is as good as none: connecting again + /// replaces it. + async fn load_tokens(&self) -> Result, AppError> { + Ok(self + .secret(Secret::DriveSession) + .await? + .and_then(|record| serde_json::from_str(&record).ok())) + } + + /// Tokens first, so an interruption never shows a connection without them. + async fn save_connection( + &self, + connection: DriveConnection, + tokens: DriveTokens, + ) -> Result<(), AppError> { + self.store_drive_tokens(&tokens).await?; + self.update(|settings| { + settings.drive_email = Some(connection.email); + settings.drive_permission_id = Some(connection.permission_id); + settings.drive_folder_id = Some(connection.folder_id); + }) + .await + } + + async fn replace_tokens(&self, tokens: DriveTokens) -> Result<(), AppError> { + self.store_drive_tokens(&tokens).await + } + + async fn save_folder_id(&self, folder_id: String) -> Result<(), AppError> { + self.update(|settings| settings.drive_folder_id = Some(folder_id)) + .await + } + + async fn dismiss_onboarding(&self) -> Result<(), AppError> { + self.update(|settings| settings.drive_onboarding_dismissed = true) + .await + } + + /// Tokens first, so an interruption leaves Drive disconnected. + async fn clear(&self) -> Result<(), AppError> { + self.store_secret(Secret::DriveSession, None).await?; + self.update(|settings| { + settings.drive_email = None; + settings.drive_permission_id = None; + settings.drive_folder_id = None; + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::LocalSettingsStore; + use crate::adapters::outbound::secrets::{InMemorySecrets, SecretStore, SettingsFile}; + use crate::application::ports::DriveSessionStore; + use crate::domain::drive::{DriveConnection, DriveStatus, DriveTokens}; + use std::path::PathBuf; + use std::sync::Arc; + use uuid::Uuid; + + type TestResult = Result<(), Box>; + + fn directory() -> PathBuf { + std::env::temp_dir().join(format!("galpi-drive-settings-{}", Uuid::now_v7())) + } + + fn tokens(refresh: &str) -> DriveTokens { + DriveTokens { + access_token: "galpi-test-access".to_owned(), + refresh_token: refresh.to_owned(), + expires_at: 9_000, + } + } + + fn connection() -> DriveConnection { + DriveConnection { + email: "user@example.com".to_owned(), + permission_id: "perm-1".to_owned(), + folder_id: "folder-1".to_owned(), + } + } + + fn store(path: PathBuf, secrets: Arc) -> LocalSettingsStore { + LocalSettingsStore::with_secrets(path, secrets) + } + + #[tokio::test] + async fn the_settings_file_backend_keeps_the_drive_connection_across_a_restart() -> TestResult { + // Given: an empty install where the settings file is the secret store + let directory = directory(); + let path = directory.join("settings.json"); + store(path.clone(), Arc::new(SettingsFile)) + .save_connection(connection(), tokens("galpi-test-refresh-1")) + .await?; + + // When: the next launch reads it back + let restarted = store(path.clone(), Arc::new(SettingsFile)); + + // Then + assert_eq!(restarted.load_connection().await?, Some(connection())); + assert_eq!( + restarted.load_tokens().await?, + Some(tokens("galpi-test-refresh-1")) + ); + assert_eq!( + restarted.load_status().await?, + DriveStatus::Connected { + email: "user@example.com".to_owned() + } + ); + tokio::fs::remove_dir_all(directory).await?; + Ok(()) + } + + #[tokio::test] + async fn an_os_secret_store_keeps_the_drive_tokens_out_of_the_settings_file() -> TestResult { + // Given: a store that keeps secrets outside the settings file + let directory = directory(); + let path = directory.join("settings.json"); + let secrets = Arc::new(InMemorySecrets::default()); + store(path.clone(), secrets.clone()) + .save_connection(connection(), tokens("galpi-test-refresh-1")) + .await?; + + // When: the next launch reads it back + let restarted = store(path.clone(), secrets); + + // Then: both survive, and the file on disk holds no token + assert_eq!( + restarted.load_tokens().await?, + Some(tokens("galpi-test-refresh-1")) + ); + let document = tokio::fs::read_to_string(&path).await?; + assert!(!document.contains("galpi-test"), "{document}"); + assert!( + document.contains("\"driveSessionStored\":true"), + "{document}" + ); + assert!( + document.contains("\"driveFolderId\":\"folder-1\""), + "{document}" + ); + tokio::fs::remove_dir_all(directory).await?; + Ok(()) + } + + #[tokio::test] + async fn clearing_the_drive_connection_removes_an_otherwise_empty_settings_file() -> TestResult + { + // Given: a Drive connection is the only thing on file + let directory = directory(); + let path = directory.join("settings.json"); + let store = store(path.clone(), Arc::new(SettingsFile)); + store + .save_connection(connection(), tokens("galpi-test-refresh-1")) + .await?; + assert!(tokio::fs::try_exists(&path).await?); + + // When + store.clear().await?; + + // Then + assert_eq!(store.load_tokens().await?, None); + assert_eq!(store.load_connection().await?, None); + assert!(!tokio::fs::try_exists(&path).await?); + let _removed = tokio::fs::remove_dir_all(directory).await; + Ok(()) + } + + #[tokio::test] + async fn the_onboarding_choice_outlives_a_disconnect() -> TestResult { + // Given + let directory = directory(); + let path = directory.join("settings.json"); + let store = store(path.clone(), Arc::new(SettingsFile)); + store.dismiss_onboarding().await?; + store + .save_connection(connection(), tokens("galpi-test-refresh-1")) + .await?; + + // When + store.clear().await?; + store + .save_folder_id("ignored-after-restart".to_owned()) + .await?; + let restarted = LocalSettingsStore::with_secrets(path, Arc::new(SettingsFile)); + + // Then: still disconnected (no account), and the card stays dismissed + assert_eq!( + restarted.load_status().await?, + DriveStatus::Disconnected { + onboarding_dismissed: true + } + ); + assert_eq!(restarted.load_connection().await?, None); + tokio::fs::remove_dir_all(directory).await?; + Ok(()) + } + + #[tokio::test] + async fn a_settings_file_from_before_drive_still_parses() -> TestResult { + // Given: a document written by a build without any Drive field + let directory = directory(); + let path = directory.join("settings.json"); + tokio::fs::create_dir_all(&directory).await?; + tokio::fs::write( + &path, + r#"{"gatewayEmail":"user@example.com","gatewaySessionStored":false}"#, + ) + .await?; + + // When + let store = store(path, Arc::new(SettingsFile)); + + // Then + assert_eq!( + store.load_status().await?, + DriveStatus::Disconnected { + onboarding_dismissed: false + } + ); + assert_eq!(store.load_tokens().await?, None); + tokio::fs::remove_dir_all(directory).await?; + Ok(()) + } +} diff --git a/src-tauri/src/adapters/outbound/transcription.rs b/src-tauri/src/adapters/outbound/transcription.rs index c8d1611..74c4c8a 100644 --- a/src-tauri/src/adapters/outbound/transcription.rs +++ b/src-tauri/src/adapters/outbound/transcription.rs @@ -178,6 +178,7 @@ async fn validate_artifacts( minutes: None, output_directory: root, source_audio: Some(source_audio.to_path_buf()), + source_transcript: None, }) } diff --git a/src-tauri/src/application/drive.rs b/src-tauri/src/application/drive.rs new file mode 100644 index 0000000..f9970b7 --- /dev/null +++ b/src-tauri/src/application/drive.rs @@ -0,0 +1,536 @@ +//! Google Drive use cases: connecting, importing a recording through the +//! desktop Picker, and syncing a meeting's results. +//! +//! The host owns the Drive tokens. Exchanges and renewals go through the +//! auth-gateway, which needs a fresh gateway bearer each time, so the bearer +//! is fetched right before every gateway call rather than once per use case: +//! a consent page can stay open for minutes. +//! +//! Every connection and token write happens under `store_lock` and, apart +//! from connecting, only while a connection is still on file. A disconnect +//! takes the same lock and clears the store, so nothing written by a sync, +//! Picker grant, or token renewal that was in flight can bring the connection +//! back. Dismissing the onboarding card touches neither and takes no lock. + +use crate::application::error::AppError; +use crate::application::gateway::{AppAccessGate, auth_required}; +use crate::application::ports::{ + ClockPort, DriveAuthPort, DriveAuthorize, DriveCallback, DriveFilesPort, DriveSessionStore, +}; +use crate::domain::artifact::{ArtifactKind, Artifacts}; +use crate::domain::drive::{ + DRIVE_ACCESS_REFRESH_MARGIN_SECONDS, DriveConnection, DriveFileId, DriveMeeting, DriveStatus, + DriveSyncFile, DriveSyncResult, DriveSyncState, DriveTokenFailure, DriveTokens, MeetingKey, + SyncDecision, decide, +}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::oneshot; + +/// The result files a sync uploads, in display order. The checkpoint stays local. +const SYNCED_KINDS: [ArtifactKind; 3] = [ + ArtifactKind::Srt, + ArtifactKind::SpeakerText, + ArtifactKind::Minutes, +]; + +fn not_connected() -> AppError { + AppError::new("DRIVE_NOT_CONNECTED", "먼저 Google Drive를 연결해 주세요.") +} + +fn reconnect_required() -> AppError { + AppError::new( + "DRIVE_RECONNECT_REQUIRED", + "Drive 연결이 만료되었습니다. 다시 연결해 주세요.", + ) +} + +fn unavailable() -> AppError { + AppError::new( + "DRIVE_UNAVAILABLE", + "Google Drive 또는 로그인 서버에 연결하지 못했습니다. 잠시 뒤 다시 시도해 주세요.", + ) +} + +fn cancelled() -> AppError { + AppError::new("CANCELLED", "사용자가 작업을 취소했습니다.") +} + +fn state_error() -> AppError { + AppError::new("STATE_ERROR", "Drive 작업 상태 잠금이 손상되었습니다.") +} + +fn token_rejected(error: &AppError) -> bool { + error.code == "DRIVE_TOKEN_REJECTED" +} + +/// What a failed gateway token call means to the caller. `rejected` is the +/// answer to Google refusing the grant. A refused bearer means the Galpi +/// session is gone, so the host closes it too, as the window will. +async fn token_failure( + access: &AppAccessGate, + failure: DriveTokenFailure, + rejected: AppError, +) -> AppError { + match failure { + DriveTokenFailure::Rejected => rejected, + DriveTokenFailure::AuthRequired => { + access.expire().await; + auth_required() + } + DriveTokenFailure::Unavailable => unavailable(), + } +} + +/// A recording the Picker chose and the download finished. +pub struct ImportedRecording { + pub path: PathBuf, + pub id: DriveFileId, + pub name: String, +} + +/// Releases the connect slot however the attempt ends, including a dropped call. +struct OperationSlot<'a> { + drive: &'a DriveAccounts, +} + +impl Drop for OperationSlot<'_> { + fn drop(&mut self) { + if let Ok(mut cancel) = self.drive.operation_cancel.lock() { + *cancel = None; + } + self.drive.operation_active.store(false, Ordering::Release); + } +} + +pub struct DriveAccounts { + auth: Arc, + files: Arc, + store: Arc, + clock: Arc, + /// Serializes token renewals and every connection or token write, including the clear. + store_lock: tokio::sync::Mutex<()>, + /// The waiting connect consent. The Picker uses a job slot instead, so its + /// wait is cancelled like any other job. + operation_active: AtomicBool, + operation_cancel: Mutex>>, +} + +impl DriveAccounts { + pub fn new( + auth: Arc, + files: Arc, + store: Arc, + clock: Arc, + ) -> Self { + Self { + auth, + files, + store, + clock, + store_lock: tokio::sync::Mutex::new(()), + operation_active: AtomicBool::new(false), + operation_cancel: Mutex::new(None), + } + } + + pub async fn status(&self) -> Result { + self.store.load_status().await + } + + pub async fn dismiss_onboarding(&self) -> Result { + self.store.dismiss_onboarding().await?; + self.store.load_status().await + } + + /// Fail with `DRIVE_NOT_CONNECTED` unless a connection is on file. + async fn connection(&self) -> Result { + self.store + .load_connection() + .await? + .ok_or_else(not_connected) + } + + /// Consent in the browser, exchange through the gateway, then remember + /// the account and the `Galpi` folder. + pub async fn connect(&self, access: &AppAccessGate) -> Result { + let (_slot, mut cancel) = self.claim_operation()?; + let (_callback, tokens) = self + .consent( + access, + &DriveAuthorize { + picker: false, + login_hint: None, + }, + &mut cancel, + ) + .await?; + let prepared = self.prepare_connection(&tokens).await; + let _writing = self.store_lock.lock().await; + // A cancel or disconnect that arrived after the browser came back + // wins: the new grant is given back instead of stored. + if cancel.try_recv().is_ok() { + self.auth.revoke(&tokens.refresh_token).await; + return Err(cancelled()); + } + let connection = match prepared { + Ok(connection) => connection, + Err(error) => { + self.auth.revoke(&tokens.refresh_token).await; + return Err(error); + } + }; + let email = connection.email.clone(); + self.store.save_connection(connection, tokens).await?; + Ok(DriveStatus::Connected { email }) + } + + /// One browser consent, exchanged through the gateway. The bearer is + /// fetched only after the browser comes back, so a consent left open for + /// minutes still exchanges with a live gateway session. + async fn consent( + &self, + access: &AppAccessGate, + request: &DriveAuthorize, + cancel: &mut oneshot::Receiver<()>, + ) -> Result<(DriveCallback, DriveTokens), AppError> { + let callback = self.auth.authorize(request, cancel).await?; + let bearer = access.bearer().await?; + match self.auth.exchange(&bearer, &callback).await { + Ok(tokens) => Ok((callback, tokens)), + Err(failure) => { + let rejected = AppError::new( + "DRIVE_CONNECT_FAILED", + "Google이 Drive 권한을 확인하지 못했습니다. 다시 시도해 주세요.", + ); + Err(token_failure(access, failure, rejected).await) + } + } + } + + async fn prepare_connection(&self, tokens: &DriveTokens) -> Result { + let account = self.files.whoami(&tokens.access_token).await?; + let folder_id = self + .files + .ensure_root_folder(&tokens.access_token, None) + .await?; + Ok(DriveConnection { + email: account.email, + permission_id: account.permission_id, + folder_id, + }) + } + + /// Ask a waiting connect consent to stop. Harmless when none is waiting. + pub fn cancel(&self) -> Result<(), AppError> { + let sender = self + .operation_cancel + .lock() + .map_err(|_| state_error())? + .take(); + if let Some(sender) = sender { + // The receiver is gone only if the attempt already finished. + let _already_finished = sender.send(()); + } + Ok(()) + } + + /// Revoke at Google when possible, then forget the connection whatever + /// Google said. Files on Drive stay where they are. + pub async fn disconnect(&self) -> Result { + self.cancel()?; + let _writing = self.store_lock.lock().await; + // Tokens that cannot be read cannot be revoked either, and + // disconnecting must still finish. + if let Some(tokens) = self.store.load_tokens().await.ok().flatten() { + self.auth.revoke(&tokens.refresh_token).await; + } + self.store.clear().await?; + self.store.load_status().await + } + + /// A Drive access token with at least the refresh margin left. + async fn access_token(&self, access: &AppAccessGate) -> Result { + let _writing = self.store_lock.lock().await; + let tokens = self.stored_tokens().await?; + if !tokens.needs_refresh(self.clock.now_unix(), DRIVE_ACCESS_REFRESH_MARGIN_SECONDS) { + return Ok(tokens.access_token); + } + self.renew_locked(access, &tokens).await + } + + /// Pass a Drive call's result through, except a 401 (see `after_refused_token`). + async fn recovered( + &self, + access: &AppAccessGate, + result: Result, + ) -> Result { + match result { + Err(error) if token_rejected(&error) => Err(self.after_refused_token(access).await), + result => result, + } + } + + /// Answer a Drive 401: renew once whatever the expiry says, then fail the + /// operation without resending the refused request. + async fn after_refused_token(&self, access: &AppAccessGate) -> AppError { + let _writing = self.store_lock.lock().await; + let renewed = match self.stored_tokens().await { + Ok(tokens) => self.renew_locked(access, &tokens).await, + Err(error) => Err(error), + }; + renewed.err().unwrap_or_else(unavailable) + } + + /// Tokens of the connection on file; must be called under `store_lock`. + async fn stored_tokens(&self) -> Result { + if self.store.load_connection().await?.is_none() { + return Err(not_connected()); + } + self.store.load_tokens().await?.ok_or_else(not_connected) + } + + /// Renew through the gateway; must be called under `store_lock`. + async fn renew_locked( + &self, + access: &AppAccessGate, + tokens: &DriveTokens, + ) -> Result { + let bearer = access.bearer().await?; + match self.auth.refresh(&bearer, &tokens.refresh_token).await { + Ok(renewed) => { + let access_token = renewed.access_token.clone(); + self.store.replace_tokens(renewed).await?; + Ok(access_token) + } + Err(DriveTokenFailure::Rejected) => { + self.store.clear().await?; + Err(reconnect_required()) + } + Err(failure) => Err(token_failure(access, failure, reconnect_required()).await), + } + } + + /// Let the user pick one recording in the desktop Picker and download it. + /// + /// `cancel` is the caller's job slot, so the job's cancel stops both the + /// consent wait and the download. + pub async fn import( + &self, + access: &AppAccessGate, + cancel: &mut oneshot::Receiver<()>, + ) -> Result { + let (id, access_token) = self.pick(access, cancel).await?; + let file = self + .recovered(access, self.files.file_metadata(&access_token, &id).await) + .await?; + let playable = file.mime_type.starts_with("audio/") || file.mime_type.starts_with("video/"); + if !playable { + return Err(AppError::new( + "DRIVE_UNSUPPORTED_FILE", + "오디오나 영상 파일만 가져올 수 있습니다.", + )); + } + if !file.can_download { + return Err(AppError::new( + "DRIVE_DOWNLOAD_FAILED", + "Drive에서 이 파일을 내려받을 권한이 없습니다.", + )); + } + let path = self + .recovered( + access, + self.files.download(&access_token, &id, &file, cancel).await, + ) + .await?; + Ok(ImportedRecording { + path, + id, + name: file.name, + }) + } + + /// The Picker consent: the chosen file and a token that can read it. + async fn pick( + &self, + access: &AppAccessGate, + cancel: &mut oneshot::Receiver<()>, + ) -> Result<(DriveFileId, String), AppError> { + let connection = self.connection().await?; + let (callback, tokens) = self + .consent( + access, + &DriveAuthorize { + picker: true, + login_hint: Some(connection.email.clone()), + }, + cancel, + ) + .await?; + let account = match self.files.whoami(&tokens.access_token).await { + Ok(account) => account, + Err(error) => { + self.auth.revoke(&tokens.refresh_token).await; + return Err(error); + } + }; + if account.permission_id != connection.permission_id { + self.auth.revoke(&tokens.refresh_token).await; + return Err(AppError::new( + "DRIVE_ACCOUNT_MISMATCH", + format!( + "연결된 Drive 계정({})과 다른 계정에서 파일을 골랐습니다.", + connection.email + ), + )); + } + { + let _writing = self.store_lock.lock().await; + let current = self.store.load_connection().await?; + // A disconnect, or a reconnect to another account, during the + // Picker wait must not attach these tokens to the new connection. + if current.is_none_or(|current| current.permission_id != account.permission_id) { + self.auth.revoke(&tokens.refresh_token).await; + return Err(not_connected()); + } + // The newest consent covers the picked file; keep it. + self.store.replace_tokens(tokens.clone()).await?; + } + let mut picked = callback.picked.into_iter(); + match (picked.next(), picked.next()) { + (Some(id), None) => Ok((id, tokens.access_token)), + (None, _) => Err(cancelled()), + (Some(_), Some(_)) => Err(AppError::new( + "DRIVE_PICK_INVALID", + "녹음 파일은 한 번에 하나만 가져올 수 있습니다.", + )), + } + } + + /// Upload or update this meeting's results in `Galpi/`. + /// + /// One file's failure is reported in its slot and the others continue; a + /// refused token, an unreachable Drive, or a lost connection ends the + /// whole sync. + pub async fn sync( + &self, + access: &AppAccessGate, + artifacts: &Artifacts, + meeting: &DriveMeeting, + overwrite: bool, + ) -> Result { + let connection = self.connection().await?; + let token = self.access_token(access).await?; + let root = self + .recovered( + access, + self.files + .ensure_root_folder(&token, Some(&connection.folder_id)) + .await, + ) + .await?; + if root != connection.folder_id { + let _writing = self.store_lock.lock().await; + if self.store.load_connection().await?.is_some() { + self.store.save_folder_id(root.clone()).await?; + } + } + let folder = self + .recovered( + access, + self.files + .ensure_meeting_folder(&token, &root, &meeting.key, &meeting.name) + .await, + ) + .await?; + let mut files = Vec::new(); + for kind in SYNCED_KINDS { + let Some(path) = artifacts.path_for(kind) else { + continue; + }; + let synced = self + .sync_file(&token, &folder.id, &meeting.key, kind, path, overwrite) + .await; + match synced { + Ok(file) => files.push(file), + Err(error) if token_rejected(&error) => { + return Err(self.after_refused_token(access).await); + } + // Drive itself is unreachable: the next file would fail the same way. + Err(error) if error.code == "DRIVE_UNAVAILABLE" => return Err(error), + Err(error) => files.push(DriveSyncFile { + kind, + state: DriveSyncState::Failed { + message: error.message, + }, + web_view_link: None, + }), + } + } + Ok(DriveSyncResult { + folder_link: folder.link, + files, + }) + } + + async fn sync_file( + &self, + token: &str, + folder: &str, + key: &MeetingKey, + kind: ArtifactKind, + path: &Path, + overwrite: bool, + ) -> Result { + let upload = self.files.read_local(path).await?; + let remote = self.files.find_artifact(token, folder, kind).await?; + let (state, web_view_link) = + match (decide(remote.as_ref(), &upload.sha256, overwrite), remote) { + // `decide` never asks to update a file that is not there. + (SyncDecision::Create, _) | (SyncDecision::Update, None) => { + let created = self + .files + .create_file(token, folder, key, kind, &upload) + .await?; + (DriveSyncState::Uploaded, created.web_view_link) + } + (SyncDecision::Update, Some(remote)) => { + let updated = self + .files + .update_file(token, &remote.id, key, kind, &upload) + .await?; + ( + DriveSyncState::Updated, + updated.web_view_link.or(remote.web_view_link), + ) + } + (SyncDecision::Unchanged, remote) => ( + DriveSyncState::Unchanged, + remote.and_then(|remote| remote.web_view_link), + ), + (SyncDecision::Conflict, remote) => ( + DriveSyncState::Conflict, + remote.and_then(|remote| remote.web_view_link), + ), + }; + Ok(DriveSyncFile { + kind, + state, + web_view_link, + }) + } + + fn claim_operation(&self) -> Result<(OperationSlot<'_>, oneshot::Receiver<()>), AppError> { + if self.operation_active.swap(true, Ordering::AcqRel) { + return Err(AppError::new( + "DRIVE_BUSY", + "이미 Google Drive 연결이 진행 중입니다.", + )); + } + let slot = OperationSlot { drive: self }; + let (sender, receiver) = oneshot::channel(); + *self.operation_cancel.lock().map_err(|_| state_error())? = Some(sender); + Ok((slot, receiver)) + } +} diff --git a/src-tauri/src/application/gateway.rs b/src-tauri/src/application/gateway.rs index 3c31924..3749af3 100644 --- a/src-tauri/src/application/gateway.rs +++ b/src-tauri/src/application/gateway.rs @@ -6,15 +6,19 @@ //! gateway keeps the app usable with the stored one. use crate::application::error::AppError; -use crate::application::ports::{GatewayAuthPort, GatewaySessionStore}; +use crate::application::ports::{ClockPort, GatewayAuthPort, GatewaySessionStore}; use crate::domain::gateway::{AppAccess, GatewayRefreshFailure}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use tokio::sync::oneshot; +/// Renew the gateway access token once less than this many seconds remain. +const BEARER_REFRESH_MARGIN_SECONDS: u64 = 60; + pub struct AppAccessGate { auth: Arc, store: Arc, + clock: Arc, /// Visible to the application tests, which open the gate without a sign-in. pub(super) access: Mutex, /// Serializes every session write so a rotated refresh token is spent @@ -31,6 +35,13 @@ fn sign_in_failed() -> AppError { ) } +pub(super) fn auth_required() -> AppError { + AppError::new( + "AUTH_REQUIRED", + "Galpi를 사용하려면 Google로 로그인해 주세요.", + ) +} + /// Releases the sign-in slot however the sign-in ends, including a dropped call. struct SignInSlot<'a> { gate: &'a AppAccessGate, @@ -46,10 +57,15 @@ impl Drop for SignInSlot<'_> { } impl AppAccessGate { - pub fn new(auth: Arc, store: Arc) -> Self { + pub fn new( + auth: Arc, + store: Arc, + clock: Arc, + ) -> Self { Self { auth, store, + clock, access: Mutex::new(AppAccess::SignedOut), writes: tokio::sync::Mutex::new(()), sign_in_active: AtomicBool::new(false), @@ -66,13 +82,59 @@ impl AppAccessGate { pub fn require(&self) -> Result<(), AppError> { match *self.access.lock().unwrap_or_else(PoisonError::into_inner) { AppAccess::SignedIn { .. } => Ok(()), - AppAccess::SignedOut => Err(AppError::new( - "AUTH_REQUIRED", - "Galpi를 사용하려면 Google로 로그인해 주세요.", + AppAccess::SignedOut => Err(auth_required()), + } + } + + /// A gateway access token with at least a minute left, for a feature that + /// calls the gateway on the user's behalf. + /// + /// Renews under the write lock so a rotated refresh token is spent once. + /// A refusal ends the session like a failed restore does; an unreachable + /// gateway fails only this call and keeps the session. + pub async fn bearer(&self) -> Result { + self.require()?; + let _writing = self.writes.lock().await; + let Some(tokens) = self.store.load_tokens().await? else { + self.set(AppAccess::SignedOut); + return Err(auth_required()); + }; + let now = self.clock.now_unix(); + if tokens + .expires_at + .saturating_sub(BEARER_REFRESH_MARGIN_SECONDS) + > now + { + return Ok(tokens.access_token); + } + match self.auth.refresh(&tokens.refresh_token).await { + Ok(rotated) => { + let access_token = rotated.access_token.clone(); + self.store.replace_tokens(rotated).await?; + Ok(access_token) + } + Err(GatewayRefreshFailure::Rejected) => { + self.set(AppAccess::SignedOut); + self.store.clear().await?; + Err(auth_required()) + } + Err(GatewayRefreshFailure::Unavailable) => Err(AppError::new( + "GATEWAY_UNAVAILABLE", + "로그인 서버에 연결하지 못했습니다. 잠시 뒤 다시 시도해 주세요.", )), } } + /// The gateway refused a bearer this gate handed out: the session is gone + /// on the server, so close the app and forget it here too. + pub async fn expire(&self) { + self.set(AppAccess::SignedOut); + let _writing = self.writes.lock().await; + // Signed out either way; a store that cannot clear is retried at the + // next restore, which the gateway refuses the same way. + let _cleared = self.store.clear().await; + } + /// Renew the stored session once and open or close the app accordingly. pub async fn restore(&self) -> Result { let _writing = self.writes.lock().await; diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index 5491a8e..9dbf573 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -1,4 +1,5 @@ pub mod chatgpt; +pub mod drive; pub mod error; pub mod gateway; pub mod jobs; diff --git a/src-tauri/src/application/ports.rs b/src-tauri/src/application/ports.rs index 4792eb1..9f0fb39 100644 --- a/src-tauri/src/application/ports.rs +++ b/src-tauri/src/application/ports.rs @@ -2,12 +2,16 @@ use crate::application::error::AppError; use crate::application::model::{ CompletedTranscription, EnvironmentStatus, RecordingFailure, RecordingResult, RecordingStatus, }; -use crate::domain::artifact::Artifacts; +use crate::domain::artifact::{ArtifactKind, Artifacts}; use crate::domain::chatgpt::{ AgentHostId, AssistantTransport, ChatGptModel, ChatGptPreferences, ChatGptRegistration, ChatGptSettings, ChatGptSignInEvent, ChatGptTokens, RefreshFailure, RevocationOutcome, SignInGrant, SignInRequest, }; +use crate::domain::drive::{ + DriveAccount, DriveConnection, DriveFileId, DriveStatus, DriveTokenFailure, DriveTokens, + MeetingKey, RemoteFile, +}; use crate::domain::engine::{ComputeDevice, EnginePreset, EngineSelection}; use crate::domain::gateway::{GatewayGrant, GatewayRefreshFailure, GatewayTokens}; use crate::domain::job::{SetupRequest, SpeakerHint}; @@ -218,3 +222,148 @@ pub trait GatewaySessionStore: Send + Sync { pub trait ClockPort: Send + Sync { fn now_unix(&self) -> u64; } + +/// One browser consent: the plain `drive.file` grant, or the desktop Picker. +pub struct DriveAuthorize { + pub picker: bool, + /// The connected account's email, so the Picker opens on that account. + pub login_hint: Option, +} + +/// A completed consent, ready to exchange. Holds the one-time code, so it has +/// no `Debug`. +pub struct DriveCallback { + pub code: String, + pub verifier: String, + pub redirect_uri: String, + /// The files chosen in the Picker; empty for the plain grant. + pub picked: Vec, +} + +/// Google consent through the system browser, and the token exchange the +/// auth-gateway performs for Galpi, which holds no client secret. +#[async_trait] +pub trait DriveAuthPort: Send + Sync { + /// Open the consent page and wait for the callback, the cancellation, or + /// the timeout. + async fn authorize( + &self, + request: &DriveAuthorize, + cancel: &mut oneshot::Receiver<()>, + ) -> Result; + /// `bearer` is the gateway access token that vouches for the request. + async fn exchange( + &self, + bearer: &str, + callback: &DriveCallback, + ) -> Result; + /// The result keeps `refresh_token` when Google does not rotate it. + async fn refresh( + &self, + bearer: &str, + refresh_token: &str, + ) -> Result; + /// Best-effort revocation at Google; never fails the caller. + async fn revoke(&self, refresh_token: &str); +} + +/// A Drive folder Galpi created. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteFolder { + pub id: String, + pub link: Option, +} + +/// What the Picker chose, as Drive describes it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickedFile { + pub name: String, + pub mime_type: String, + pub can_download: bool, + /// RFC 3339, kept for the downloaded file's modification time. + pub modified_time: Option, +} + +/// A local result file read for upload. +pub struct LocalUpload { + pub name: String, + pub content: Vec, + pub sha256: String, +} + +/// Drive v3 on behalf of the user. An access token Google refuses yields +/// `DRIVE_TOKEN_REJECTED`, which the caller answers by renewing once. +#[async_trait] +pub trait DriveFilesPort: Send + Sync { + async fn whoami(&self, access_token: &str) -> Result; + /// The `Galpi` folder: `known` when it still exists, else found or created. + async fn ensure_root_folder( + &self, + access_token: &str, + known: Option<&str>, + ) -> Result; + async fn ensure_meeting_folder( + &self, + access_token: &str, + root: &str, + key: &MeetingKey, + name: &str, + ) -> Result; + async fn find_artifact( + &self, + access_token: &str, + folder: &str, + kind: ArtifactKind, + ) -> Result, AppError>; + /// Read a result file, refusing one too large for a single upload. + async fn read_local(&self, path: &Path) -> Result; + async fn create_file( + &self, + access_token: &str, + folder: &str, + key: &MeetingKey, + kind: ArtifactKind, + upload: &LocalUpload, + ) -> Result; + async fn update_file( + &self, + access_token: &str, + file_id: &str, + key: &MeetingKey, + kind: ArtifactKind, + upload: &LocalUpload, + ) -> Result; + async fn file_metadata( + &self, + access_token: &str, + id: &DriveFileId, + ) -> Result; + /// Stream the file into a fresh import folder and return its canonical + /// path. A failure or cancellation leaves no folder behind. + async fn download( + &self, + access_token: &str, + id: &DriveFileId, + file: &PickedFile, + cancel: &mut oneshot::Receiver<()>, + ) -> Result; +} + +/// The Drive connection on file. Only the token methods touch the secret store. +#[async_trait] +pub trait DriveSessionStore: Send + Sync { + async fn load_status(&self) -> Result; + async fn load_connection(&self) -> Result, AppError>; + async fn load_tokens(&self) -> Result, AppError>; + async fn save_connection( + &self, + connection: DriveConnection, + tokens: DriveTokens, + ) -> Result<(), AppError>; + async fn replace_tokens(&self, tokens: DriveTokens) -> Result<(), AppError>; + /// Change only the remembered `Galpi` folder. + async fn save_folder_id(&self, folder_id: String) -> Result<(), AppError>; + async fn dismiss_onboarding(&self) -> Result<(), AppError>; + /// Forget the tokens and the connection; the onboarding choice stays. + async fn clear(&self) -> Result<(), AppError>; +} diff --git a/src-tauri/src/application/tests.rs b/src-tauri/src/application/tests.rs index e601b1d..4813316 100644 --- a/src-tauri/src/application/tests.rs +++ b/src-tauri/src/application/tests.rs @@ -1,11 +1,12 @@ use super::chatgpt::ChatGptAccounts; +use super::drive::DriveAccounts; use super::error::AppError; use super::gateway::AppAccessGate; use super::model::{CompletedTranscription, EnvironmentStatus}; use super::ports::{ - ArtifactPort, ChatGptAuthPort, ChatGptStore, ClockPort, EnginePort, GatewayAuthPort, - GatewaySessionStore, RecordingPort, RefinementJob, RefinementPort, SettingsPort, - TranscriptImportPort, TranscriptionPort, + ArtifactPort, ChatGptAuthPort, ChatGptStore, ClockPort, DriveAuthPort, DriveFilesPort, + DriveSessionStore, EnginePort, GatewayAuthPort, GatewaySessionStore, RecordingPort, + RefinementJob, RefinementPort, SettingsPort, TranscriptImportPort, TranscriptionPort, }; use super::use_cases::Application; use crate::domain::artifact::{ArtifactKind, Artifacts}; @@ -78,6 +79,7 @@ struct FakePort { behavior: TranscriptionBehavior, chatgpt: chatgpt::ChatGptFake, gateway: gateway::GatewayFake, + drive: drive::DriveFake, } impl FakePort { @@ -99,6 +101,7 @@ impl FakePort { ready_presets: Mutex::new(vec![EnginePreset::Qwen3, EnginePreset::WhisperX]), chatgpt: chatgpt::ChatGptFake::new(), gateway: gateway::GatewayFake::default(), + drive: drive::DriveFake::default(), behavior, } } @@ -127,12 +130,10 @@ impl FakePort { let chatgpt_auth: Arc = self.clone(); let chatgpt_store: Arc = self.clone(); let clock: Arc = self.clone(); - let gateway_auth: Arc = self.clone(); - let gateway_store: Arc = self.clone(); - let gate = AppAccessGate::new(gateway_auth, gateway_store); - if let Ok(mut opened) = gate.access.lock() { - *opened = access; - } + let drive_auth: Arc = self.clone(); + let drive_files: Arc = self.clone(); + let drive_store: Arc = self.clone(); + let gate = self.gate(access); Application::new( engine, transcription, @@ -141,10 +142,23 @@ impl FakePort { recording, settings, refinement, - ChatGptAccounts::new(chatgpt_auth, chatgpt_store, clock), + ChatGptAccounts::new(chatgpt_auth, chatgpt_store, clock.clone()), gate, + DriveAccounts::new(drive_auth, drive_files, drive_store, clock), ) } + + /// The sign-in gate over this port's gateway fakes and clock, opened as given. + fn gate(self: &Arc, access: AppAccess) -> AppAccessGate { + let gateway_auth: Arc = self.clone(); + let gateway_store: Arc = self.clone(); + let clock: Arc = self.clone(); + let gate = AppAccessGate::new(gateway_auth, gateway_store, clock); + if let Ok(mut opened) = gate.access.lock() { + *opened = access; + } + gate + } } #[async_trait] @@ -325,15 +339,21 @@ impl TranscriptionPort for FakePort { input: &Path, output_root: &Path, ) -> Result<(PathBuf, PathBuf), AppError> { - self.prepare_calls.fetch_add(1, Ordering::SeqCst); - Ok((input.to_owned(), output_root.join("job"))) + // Like the real meeting folder rule: a second run gets `job 2`, `job 3`, … + let run = self.prepare_calls.fetch_add(1, Ordering::SeqCst) + 1; + let folder = if run == 1 { + "job".to_owned() + } else { + format!("job {run}") + }; + Ok((input.to_owned(), output_root.join(folder))) } async fn transcribe( &self, job_id: Uuid, cancel: &mut oneshot::Receiver<()>, - _input: &Path, + input: &Path, output: &Path, _hint: &SpeakerHint, engine: EnginePreset, @@ -350,7 +370,7 @@ impl TranscriptionPort for FakePort { .push(context.to_owned()); } match &self.behavior { - TranscriptionBehavior::Success => Ok(completion(output)), + TranscriptionBehavior::Success => Ok(completion(input, output)), TranscriptionBehavior::Failure => { Err(AppError::new("TRANSCRIBE_FAILED", "expected test failure")) } @@ -402,6 +422,7 @@ impl TranscriptImportPort for FakePort { minutes: None, output_directory: input.parent().unwrap_or(Path::new("/tmp")).to_owned(), source_audio: None, + source_transcript: Some(input.to_owned()), }) } } @@ -690,10 +711,11 @@ fn request(speaker_hint: SpeakerHint) -> TranscriptionRequest { input_path: "/tmp/input.m4a".to_owned(), output_root: "/tmp/output".to_owned(), speaker_hint, + drive_source_id: None, } } -fn completion(output: &Path) -> CompletedTranscription { +fn completion(input: &Path, output: &Path) -> CompletedTranscription { CompletedTranscription { artifacts: Artifacts { srt: Some(output.join("meeting.srt")), @@ -701,7 +723,8 @@ fn completion(output: &Path) -> CompletedTranscription { checkpoint: Some(output.join("meeting.aligned.v2.json")), minutes: None, output_directory: output.to_owned(), - source_audio: Some(output.join("meeting.wav")), + source_audio: Some(input.to_owned()), + source_transcript: None, }, segments: 8, filtered: 1, @@ -877,5 +900,6 @@ async fn save_compute_device_is_delegated_to_the_settings_port() -> Result<(), A } mod chatgpt; +mod drive; mod gateway; mod recording; diff --git a/src-tauri/src/application/tests/chatgpt.rs b/src-tauri/src/application/tests/chatgpt.rs index 71cbad6..d2fe86f 100644 --- a/src-tauri/src/application/tests/chatgpt.rs +++ b/src-tauri/src/application/tests/chatgpt.rs @@ -75,6 +75,11 @@ impl ChatGptFake { } } + /// Move the shared fake clock forward. + pub(super) fn advance(&self, seconds: u64) { + self.now.fetch_add(seconds, Ordering::Relaxed); + } + /// Mirrors a worker that fails the run after being asked once. pub(super) fn refine_outcome(&self) -> Result<(), AppError> { match lock(&self.refine_failure)?.as_ref() { diff --git a/src-tauri/src/application/tests/drive.rs b/src-tauri/src/application/tests/drive.rs new file mode 100644 index 0000000..26512bd --- /dev/null +++ b/src-tauri/src/application/tests/drive.rs @@ -0,0 +1,1245 @@ +use super::gateway::tokens_expiring; +use super::{FakePort, TranscriptionBehavior, request}; +use crate::application::error::AppError; +use crate::application::ports::{ + ClockPort, DriveAuthPort, DriveAuthorize, DriveCallback, DriveFilesPort, DriveSessionStore, + LocalUpload, PickedFile, RemoteFolder, +}; +use crate::application::use_cases::Application; +use crate::domain::artifact::ArtifactKind; +use crate::domain::drive::{ + DriveAccount, DriveConnection, DriveFileId, DriveStatus, DriveSyncState, DriveTokenFailure, + DriveTokens, MeetingKey, RemoteFile, parse_picked_ids, +}; +use crate::domain::gateway::GatewayRefreshFailure; +use crate::domain::job::{SpeakerHint, TranscriptImportRequest, TranscriptionRequest}; +use async_trait::async_trait; +use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use tokio::sync::{Notify, oneshot}; +use uuid::Uuid; + +fn lock(mutex: &Mutex) -> Result, AppError> { + mutex + .lock() + .map_err(|_| AppError::new("TEST_ERROR", "drive fake lock poisoned")) +} + +#[derive(Default)] +struct Stored { + connection: Option, + tokens: Option, + dismissed: bool, +} + +#[derive(Default)] +struct Calls { + consents: Vec<(bool, Option)>, + exchange_bearers: Vec, + refresh_bearers: Vec, + revoked: Vec, + created: Vec, + updated: Vec, +} + +/// How the next browser consent ends. +enum Consent { + /// Come back at once with these picked ids. + Approve(&'static str), + /// Wait until released (then approve with the default pick) or cancelled. + Wait, +} + +#[derive(Clone)] +struct RemoteEntry { + id: String, + checksum: Option, + synced: Option, +} + +#[derive(Default)] +struct RemoteDrive { + root: Option, + meetings: Vec<(String, String)>, + files: HashMap<(String, ArtifactKind), RemoteEntry>, + next: usize, +} + +impl RemoteDrive { + fn new_id(&mut self, prefix: &str) -> String { + self.next += 1; + format!("{prefix}-{}", self.next) + } +} + +/// The Drive side of `FakePort`: an in-memory store, a scripted consent and +/// gateway, and an in-memory Drive that answers like the real one. +pub(super) struct DriveFake { + stored: Mutex, + calls: Mutex, + consents: Mutex>, + consent_started: Notify, + consent_release: Notify, + exchanges: Mutex>>, + refreshes: Mutex>>, + account: Mutex, + remote: Mutex, + local: Mutex>, + picked: Mutex, + reject_files: AtomicBool, + /// Uploads of this kind fail with this code. + failing_kind: Mutex>, + block_download: AtomicBool, + download_started: Notify, +} + +impl Default for DriveFake { + fn default() -> Self { + Self { + stored: Mutex::new(Stored::default()), + calls: Mutex::new(Calls::default()), + consents: Mutex::new(VecDeque::new()), + consent_started: Notify::new(), + consent_release: Notify::new(), + exchanges: Mutex::new(VecDeque::new()), + refreshes: Mutex::new(VecDeque::new()), + account: Mutex::new(DriveAccount { + email: "user@example.com".to_owned(), + permission_id: "perm-1".to_owned(), + }), + remote: Mutex::new(RemoteDrive::default()), + local: Mutex::new(HashMap::new()), + picked: Mutex::new(PickedFile { + name: "주간 회의.m4a".to_owned(), + mime_type: "audio/x-m4a".to_owned(), + can_download: true, + modified_time: Some("2026-10-01T09:30:00Z".to_owned()), + }), + reject_files: AtomicBool::new(false), + failing_kind: Mutex::new(None), + block_download: AtomicBool::new(false), + download_started: Notify::new(), + } + } +} + +impl DriveFake { + fn stored(&self) -> Result, AppError> { + lock(&self.stored) + } + + fn calls(&self) -> Result, AppError> { + lock(&self.calls) + } + + fn remote(&self) -> Result, AppError> { + lock(&self.remote) + } + + fn push_consent(&self, consent: Consent) -> Result<(), AppError> { + lock(&self.consents)?.push_back(consent); + Ok(()) + } + + fn script_refresh( + &self, + result: Result, + ) -> Result<(), AppError> { + lock(&self.refreshes)?.push_back(result); + Ok(()) + } + + fn rejecting(&self) -> Result<(), AppError> { + if self.reject_files.load(Ordering::Relaxed) { + return Err(AppError::new("DRIVE_TOKEN_REJECTED", "rejected by test")); + } + Ok(()) + } + + fn failing(&self, kind: ArtifactKind) -> Result<(), AppError> { + match *lock(&self.failing_kind)? { + Some((failing, code)) if failing == kind => Err(AppError::new(code, "저장 공간 부족")), + _ => Ok(()), + } + } + + /// Someone edits the Drive copy of `kind` outside Galpi. + fn edit_remote(&self, kind: ArtifactKind) -> Result<(), AppError> { + for ((_, entry_kind), entry) in &mut self.remote()?.files { + if *entry_kind == kind { + entry.checksum = Some("edited-elsewhere".to_owned()); + } + } + Ok(()) + } + + fn remote_id(&self, kind: ArtifactKind) -> Result, AppError> { + Ok(self + .remote()? + .files + .iter() + .find(|((_, entry_kind), _)| *entry_kind == kind) + .map(|(_, entry)| entry.id.clone())) + } + + fn remote_checksum(&self, kind: ArtifactKind) -> Result, AppError> { + Ok(self + .remote()? + .files + .iter() + .find(|((_, entry_kind), _)| *entry_kind == kind) + .and_then(|(_, entry)| entry.checksum.clone())) + } +} + +fn drive_tokens(port: &FakePort, name: &str, seconds_left: u64) -> DriveTokens { + DriveTokens { + access_token: format!("galpi-test-drive-access-{name}"), + refresh_token: format!("galpi-test-drive-refresh-{name}"), + expires_at: port.now_unix() + seconds_left, + } +} + +fn callback(picked: &str) -> Result { + Ok(DriveCallback { + code: "galpi-test-code".to_owned(), + verifier: "galpi-test-verifier".to_owned(), + redirect_uri: "http://127.0.0.1:1455/auth/callback".to_owned(), + picked: parse_picked_ids(picked).map_err(|_| AppError::new("TEST_ERROR", "bad pick"))?, + }) +} + +#[async_trait] +impl DriveAuthPort for FakePort { + async fn authorize( + &self, + request: &DriveAuthorize, + cancel: &mut oneshot::Receiver<()>, + ) -> Result { + self.drive + .calls()? + .consents + .push((request.picker, request.login_hint.clone())); + let default_pick = if request.picker { "file-1" } else { "" }; + let consent = lock(&self.drive.consents)?.pop_front(); + match consent { + Some(Consent::Approve(picked)) => callback(picked), + None => callback(default_pick), + Some(Consent::Wait) => { + self.drive.consent_started.notify_one(); + tokio::select! { + _ = &mut *cancel => Err(AppError::new("CANCELLED", "사용자가 작업을 취소했습니다.")), + () = self.drive.consent_release.notified() => callback(default_pick), + } + } + } + } + + async fn exchange( + &self, + bearer: &str, + _callback: &DriveCallback, + ) -> Result { + if let Ok(mut calls) = self.drive.calls() { + calls.exchange_bearers.push(bearer.to_owned()); + } + self.drive + .exchanges + .lock() + .ok() + .and_then(|mut queue| queue.pop_front()) + .unwrap_or_else(|| Ok(drive_tokens(self, "exchanged", 3_600))) + } + + async fn refresh( + &self, + bearer: &str, + _refresh_token: &str, + ) -> Result { + if let Ok(mut calls) = self.drive.calls() { + calls.refresh_bearers.push(bearer.to_owned()); + } + self.drive + .refreshes + .lock() + .ok() + .and_then(|mut queue| queue.pop_front()) + .unwrap_or(Err(DriveTokenFailure::Unavailable)) + } + + async fn revoke(&self, refresh_token: &str) { + if let Ok(mut calls) = self.drive.calls() { + calls.revoked.push(refresh_token.to_owned()); + } + } +} + +#[async_trait] +impl DriveFilesPort for FakePort { + async fn whoami(&self, _access_token: &str) -> Result { + self.drive.rejecting()?; + Ok(lock(&self.drive.account)?.clone()) + } + + async fn ensure_root_folder( + &self, + _access_token: &str, + _known: Option<&str>, + ) -> Result { + self.drive.rejecting()?; + // One marked `Galpi` folder at most, like the real lookup. + let mut remote = self.drive.remote()?; + if let Some(root) = remote.root.clone() { + return Ok(root); + } + let root = remote.new_id("root"); + remote.root = Some(root.clone()); + Ok(root) + } + + async fn ensure_meeting_folder( + &self, + _access_token: &str, + _root: &str, + key: &MeetingKey, + _name: &str, + ) -> Result { + self.drive.rejecting()?; + let mut remote = self.drive.remote()?; + let existing = remote + .meetings + .iter() + .find(|(meeting, _)| meeting == key.as_str()) + .map(|(_, id)| id.clone()); + let id = if let Some(id) = existing { + id + } else { + let id = remote.new_id("meeting"); + remote.meetings.push((key.as_str().to_owned(), id.clone())); + id + }; + Ok(RemoteFolder { + link: Some(format!("https://drive.google.com/drive/folders/{id}")), + id, + }) + } + + async fn find_artifact( + &self, + _access_token: &str, + folder: &str, + kind: ArtifactKind, + ) -> Result, AppError> { + self.drive.rejecting()?; + Ok(self + .drive + .remote()? + .files + .get(&(folder.to_owned(), kind)) + .map(|entry| RemoteFile { + id: entry.id.clone(), + sha256_checksum: entry.checksum.clone(), + synced_sha256: entry.synced.clone(), + web_view_link: Some(format!("https://drive.google.com/file/d/{}", entry.id)), + })) + } + + async fn read_local(&self, path: &Path) -> Result { + let content = lock(&self.drive.local)? + .get(path) + .cloned() + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + Ok(LocalUpload { + name: path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(), + // The fake's "checksum" is the content itself: equal content, equal sum. + sha256: content.clone(), + content: content.into_bytes(), + }) + } + + async fn create_file( + &self, + _access_token: &str, + folder: &str, + _key: &MeetingKey, + kind: ArtifactKind, + upload: &LocalUpload, + ) -> Result { + self.drive.rejecting()?; + self.drive.failing(kind)?; + let mut remote = self.drive.remote()?; + let id = remote.new_id("file"); + remote.files.insert( + (folder.to_owned(), kind), + RemoteEntry { + id: id.clone(), + checksum: Some(upload.sha256.clone()), + synced: Some(upload.sha256.clone()), + }, + ); + self.drive.calls()?.created.push(kind); + Ok(RemoteFile { + web_view_link: Some(format!("https://drive.google.com/file/d/{id}")), + id, + sha256_checksum: Some(upload.sha256.clone()), + synced_sha256: Some(upload.sha256.clone()), + }) + } + + async fn update_file( + &self, + _access_token: &str, + file_id: &str, + _key: &MeetingKey, + kind: ArtifactKind, + upload: &LocalUpload, + ) -> Result { + self.drive.rejecting()?; + self.drive.failing(kind)?; + for entry in self.drive.remote()?.files.values_mut() { + if entry.id == file_id { + entry.checksum = Some(upload.sha256.clone()); + entry.synced = Some(upload.sha256.clone()); + } + } + self.drive.calls()?.updated.push(file_id.to_owned()); + Ok(RemoteFile { + id: file_id.to_owned(), + sha256_checksum: Some(upload.sha256.clone()), + synced_sha256: Some(upload.sha256.clone()), + web_view_link: None, + }) + } + + async fn file_metadata( + &self, + _access_token: &str, + _id: &DriveFileId, + ) -> Result { + self.drive.rejecting()?; + Ok(lock(&self.drive.picked)?.clone()) + } + + async fn download( + &self, + _access_token: &str, + id: &DriveFileId, + file: &PickedFile, + cancel: &mut oneshot::Receiver<()>, + ) -> Result { + self.drive.rejecting()?; + if self.drive.block_download.load(Ordering::Relaxed) { + self.drive.download_started.notify_one(); + let _cancelled = cancel.await; + return Err(AppError::new("CANCELLED", "사용자가 작업을 취소했습니다.")); + } + Ok(PathBuf::from(format!( + "/tmp/drive-imports/{}/{}", + id.as_str(), + file.name + ))) + } +} + +#[async_trait] +impl DriveSessionStore for FakePort { + async fn load_status(&self) -> Result { + let stored = self.drive.stored()?; + Ok(match &stored.connection { + Some(connection) => DriveStatus::Connected { + email: connection.email.clone(), + }, + None => DriveStatus::Disconnected { + onboarding_dismissed: stored.dismissed, + }, + }) + } + + async fn load_connection(&self) -> Result, AppError> { + Ok(self.drive.stored()?.connection.clone()) + } + + async fn load_tokens(&self) -> Result, AppError> { + Ok(self.drive.stored()?.tokens.clone()) + } + + async fn save_connection( + &self, + connection: DriveConnection, + tokens: DriveTokens, + ) -> Result<(), AppError> { + let mut stored = self.drive.stored()?; + stored.connection = Some(connection); + stored.tokens = Some(tokens); + Ok(()) + } + + async fn replace_tokens(&self, tokens: DriveTokens) -> Result<(), AppError> { + self.drive.stored()?.tokens = Some(tokens); + Ok(()) + } + + async fn save_folder_id(&self, folder_id: String) -> Result<(), AppError> { + if let Some(connection) = &mut self.drive.stored()?.connection { + connection.folder_id = folder_id; + } + Ok(()) + } + + async fn dismiss_onboarding(&self) -> Result<(), AppError> { + self.drive.stored()?.dismissed = true; + Ok(()) + } + + async fn clear(&self) -> Result<(), AppError> { + let mut stored = self.drive.stored()?; + stored.connection = None; + stored.tokens = None; + Ok(()) + } +} + +/// A signed-in app whose gateway session is good for an hour. +fn signed_in() -> Result<(Arc, Application), AppError> { + let port = Arc::new(FakePort::new(TranscriptionBehavior::Success)); + port.gateway + .seed(tokens_expiring(&port, "session", 3_600), None)?; + let app = port.application(); + Ok((port, app)) +} + +/// A signed-in app with Drive connected to `root-saved`, which exists on Drive. +fn connected() -> Result<(Arc, Application), AppError> { + let (port, app) = signed_in()?; + { + let mut stored = port.drive.stored()?; + stored.connection = Some(DriveConnection { + email: "user@example.com".to_owned(), + permission_id: "perm-1".to_owned(), + folder_id: "root-saved".to_owned(), + }); + stored.tokens = Some(drive_tokens(&port, "stored", 3_600)); + } + port.drive.remote()?.root = Some("root-saved".to_owned()); + Ok((port, app)) +} + +fn failure(result: Result) -> Result { + match result { + Ok(_) => Err(AppError::new("TEST_ERROR", "expected a failure")), + Err(error) => Ok(error), + } +} + +fn joined( + result: Result, tokio::task::JoinError>, +) -> Result, AppError> { + result.map_err(|error| AppError::new("TEST_JOIN", error.to_string())) +} + +fn states(result: &crate::domain::drive::DriveSyncResult) -> Vec<(ArtifactKind, DriveSyncState)> { + result + .files + .iter() + .map(|file| (file.kind, file.state.clone())) + .collect() +} + +fn stored_tokens(port: &FakePort) -> Result, AppError> { + Ok(port.drive.stored()?.tokens.clone()) +} + +#[tokio::test] +async fn drive_features_require_sign_in() -> Result<(), AppError> { + // Given + let port = Arc::new(FakePort::new(TranscriptionBehavior::Success)); + let app = port.signed_out_application(); + + // When + let codes = [ + failure(app.connect_drive().await)?.code, + failure(app.import_from_drive(Uuid::now_v7()).await)?.code, + failure(app.sync_meeting_to_drive(Uuid::now_v7(), false).await)?.code, + ]; + + // Then: only the feature commands are closed + assert_eq!(codes, ["AUTH_REQUIRED", "AUTH_REQUIRED", "AUTH_REQUIRED"]); + assert_eq!( + app.load_drive_status().await?, + DriveStatus::Disconnected { + onboarding_dismissed: false + } + ); + assert_eq!(port.drive.calls()?.consents, Vec::new()); + Ok(()) +} + +#[tokio::test] +async fn connecting_stores_the_account_and_the_galpi_folder() -> Result<(), AppError> { + // Given + let (port, app) = signed_in()?; + + // When + let status = app.connect_drive().await?; + + // Then + assert_eq!( + status, + DriveStatus::Connected { + email: "user@example.com".to_owned() + } + ); + let stored = port.drive.stored()?; + let connection = stored + .connection + .clone() + .ok_or_else(|| AppError::new("TEST_ERROR", "no connection"))?; + assert_eq!(connection.permission_id, "perm-1"); + assert_eq!(connection.folder_id, "root-1"); + assert_eq!( + stored + .tokens + .as_ref() + .map(|tokens| tokens.refresh_token.as_str()), + Some("galpi-test-drive-refresh-exchanged") + ); + assert_eq!( + port.drive.calls()?.exchange_bearers, + ["galpi-test-access-session"] + ); + assert_eq!(port.drive.calls()?.consents, [(false, None)]); + Ok(()) +} + +#[tokio::test] +async fn one_connect_waits_at_a_time_and_cancelling_allows_another() -> Result<(), AppError> { + // Given: a connect waiting for the browser + let (port, app) = signed_in()?; + port.drive.push_consent(Consent::Wait)?; + let app = Arc::new(app); + let first = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.connect_drive().await }) + }; + port.drive.consent_started.notified().await; + + // When + let second = failure(app.connect_drive().await)?; + app.cancel_drive_operation()?; + let first = failure(joined(first.await)?)?; + + // Then + assert_eq!(second.code, "DRIVE_BUSY"); + assert_eq!(first.code, "CANCELLED"); + assert_eq!(port.drive.stored()?.connection, None); + assert_eq!(port.drive.calls()?.exchange_bearers, Vec::::new()); + assert!(matches!( + app.connect_drive().await?, + DriveStatus::Connected { .. } + )); + Ok(()) +} + +#[tokio::test] +async fn the_exchange_uses_a_gateway_bearer_renewed_after_a_long_consent() -> Result<(), AppError> { + // Given: a gateway session with two minutes left and a slow consent + let (port, app) = signed_in()?; + port.gateway + .seed(tokens_expiring(&port, "short", 120), None)?; + port.drive.push_consent(Consent::Wait)?; + let app = Arc::new(app); + let connecting = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.connect_drive().await }) + }; + port.drive.consent_started.notified().await; + + // When: the session is about to expire by the time the browser returns + port.chatgpt.advance(100); + port.gateway + .script_refresh(Ok(tokens_expiring(&port, "renewed", 3_600)))?; + port.drive.consent_release.notify_one(); + joined(connecting.await)??; + + // Then + assert_eq!( + port.drive.calls()?.exchange_bearers, + ["galpi-test-access-renewed"] + ); + Ok(()) +} + +#[tokio::test] +async fn importing_with_another_account_is_refused_and_its_grant_revoked() -> Result<(), AppError> { + // Given + let (port, app) = connected()?; + lock(&port.drive.account)?.permission_id = "perm-2".to_owned(); + let before = stored_tokens(&port)?; + + // When + let error = failure(app.import_from_drive(Uuid::now_v7()).await)?; + + // Then + assert_eq!(error.code, "DRIVE_ACCOUNT_MISMATCH"); + assert!(error.message.contains("user@example.com")); + assert_eq!(stored_tokens(&port)?, before); + assert_eq!( + port.drive.calls()?.revoked, + ["galpi-test-drive-refresh-exchanged"] + ); + assert_eq!( + port.drive.calls()?.consents, + [(true, Some("user@example.com".to_owned()))] + ); + // And the job slot is free again + app.transcribe(request(SpeakerHint::Auto)).await?; + Ok(()) +} + +#[tokio::test] +async fn importing_with_the_same_account_keeps_the_new_grant() -> Result<(), AppError> { + // Given + let (port, app) = connected()?; + let job_id = Uuid::now_v7(); + + // When + let imported = app.import_from_drive(job_id).await?; + + // Then + assert_eq!(imported.job_id, job_id); + assert_eq!(imported.drive_file_id.as_str(), "file-1"); + assert_eq!(imported.name, "주간 회의.m4a"); + assert_eq!( + imported.input_path, + "/tmp/drive-imports/file-1/주간 회의.m4a" + ); + assert_eq!( + stored_tokens(&port)?.map(|tokens| tokens.refresh_token), + Some("galpi-test-drive-refresh-exchanged".to_owned()) + ); + assert_eq!(port.drive.calls()?.revoked, Vec::::new()); + Ok(()) +} + +#[tokio::test] +async fn picking_a_google_document_or_nothing_imports_nothing() -> Result<(), AppError> { + // Given + let (port, app) = connected()?; + lock(&port.drive.picked)?.mime_type = "application/vnd.google-apps.document".to_owned(); + + // When + let document = failure(app.import_from_drive(Uuid::now_v7()).await)?; + port.drive.push_consent(Consent::Approve(""))?; + let nothing = failure(app.import_from_drive(Uuid::now_v7()).await)?; + port.drive.push_consent(Consent::Approve("a,b"))?; + let two = failure(app.import_from_drive(Uuid::now_v7()).await)?; + + // Then + assert_eq!(document.code, "DRIVE_UNSUPPORTED_FILE"); + assert_eq!(nothing.code, "CANCELLED"); + assert_eq!(two.code, "DRIVE_PICK_INVALID"); + Ok(()) +} + +#[tokio::test] +async fn cancelling_the_import_job_stops_the_picker_wait_and_frees_the_slot() -> Result<(), AppError> +{ + // Given: an import waiting for the Picker + let (port, app) = connected()?; + port.drive.push_consent(Consent::Wait)?; + let app = Arc::new(app); + let job_id = Uuid::now_v7(); + let importing = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.import_from_drive(job_id).await }) + }; + port.drive.consent_started.notified().await; + + // When + app.cancel(job_id)?; + let error = failure(joined(importing.await)?)?; + + // Then + assert_eq!(error.code, "CANCELLED"); + app.transcribe(request(SpeakerHint::Auto)).await?; + Ok(()) +} + +#[tokio::test] +async fn cancelling_the_import_job_stops_the_download_and_frees_the_slot() -> Result<(), AppError> { + // Given: an import that is downloading + let (port, app) = connected()?; + port.drive.block_download.store(true, Ordering::Relaxed); + let app = Arc::new(app); + let job_id = Uuid::now_v7(); + let importing = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.import_from_drive(job_id).await }) + }; + port.drive.download_started.notified().await; + + // When + app.cancel(job_id)?; + let error = failure(joined(importing.await)?)?; + + // Then + assert_eq!(error.code, "CANCELLED"); + app.transcribe(request(SpeakerHint::Auto)).await?; + Ok(()) +} + +#[tokio::test] +async fn a_transcription_must_match_the_drive_import_it_claims() -> Result<(), AppError> { + // Given + let (port, app) = connected()?; + let imported = app.import_from_drive(Uuid::now_v7()).await?; + let from_drive = |drive_source_id: &str| TranscriptionRequest { + job_id: Uuid::now_v7(), + input_path: imported.input_path.clone(), + output_root: "/tmp/output".to_owned(), + speaker_hint: SpeakerHint::Auto, + drive_source_id: Some(drive_source_id.to_owned()), + }; + + // When + let mismatch = failure(app.transcribe(from_drive("someone-else")).await)?; + let other_file = failure( + app.transcribe(TranscriptionRequest { + input_path: "/tmp/input.m4a".to_owned(), + ..from_drive("file-1") + }) + .await, + )?; + let transcribed = app.transcribe(from_drive("file-1")).await?; + app.sync_meeting_to_drive(transcribed.job_id, false).await?; + + // Then: refused before any work, and the match lands in the Drive file's folder + assert_eq!(mismatch.code, "DRIVE_SOURCE_MISMATCH"); + assert_eq!(other_file.code, "DRIVE_SOURCE_MISMATCH"); + assert_eq!(port.prepare_calls.load(Ordering::SeqCst), 1); + let meetings = port.drive.remote()?.meetings.clone(); + assert_eq!( + meetings + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + ["drive:file-1"] + ); + Ok(()) +} + +#[tokio::test] +async fn retranscribing_a_local_file_syncs_into_the_same_drive_folder() -> Result<(), AppError> { + // Given: the same audio transcribed twice (the second into `job 2`) + let (port, app) = connected()?; + let first = app.transcribe(request(SpeakerHint::Auto)).await?; + let second = app.transcribe(request(SpeakerHint::Auto)).await?; + app.sync_meeting_to_drive(first.job_id, false).await?; + let first_txt = port.drive.remote_id(ArtifactKind::SpeakerText)?; + + // When + let result = app.sync_meeting_to_drive(second.job_id, false).await?; + + // Then + assert_eq!(port.drive.remote()?.meetings.len(), 1); + assert_eq!(port.drive.remote_id(ArtifactKind::SpeakerText)?, first_txt); + assert!(result.files.iter().all(|file| matches!( + file.state, + DriveSyncState::Updated | DriveSyncState::Unchanged + ))); + assert_eq!(port.drive.calls()?.created.len(), 2); + Ok(()) +} + +#[tokio::test] +async fn syncing_uploads_skips_updates_and_holds_a_conflict_until_overwritten() +-> Result<(), AppError> { + // Given + let (port, app) = connected()?; + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + let job = transcribed.job_id; + let txt = PathBuf::from(&transcribed.txt); + + // When / Then: the first sync uploads both files + let first = app.sync_meeting_to_drive(job, false).await?; + assert_eq!( + states(&first), + [ + (ArtifactKind::Srt, DriveSyncState::Uploaded), + (ArtifactKind::SpeakerText, DriveSyncState::Uploaded), + ] + ); + assert!(first.folder_link.is_some()); + let txt_id = port.drive.remote_id(ArtifactKind::SpeakerText)?; + + // Same content again: nothing changes + let again = app.sync_meeting_to_drive(job, false).await?; + assert!( + again + .files + .iter() + .all(|file| file.state == DriveSyncState::Unchanged) + ); + + // The local transcript changes: the same Drive file is updated + lock(&port.drive.local)?.insert(txt.clone(), "고친 전사".to_owned()); + let changed = app.sync_meeting_to_drive(job, false).await?; + assert_eq!( + states(&changed)[1], + (ArtifactKind::SpeakerText, DriveSyncState::Updated) + ); + assert_eq!(port.drive.remote_id(ArtifactKind::SpeakerText)?, txt_id); + + // Someone edits the Drive copy, and the local file changes again + port.drive.edit_remote(ArtifactKind::SpeakerText)?; + lock(&port.drive.local)?.insert(txt, "다시 고친 전사".to_owned()); + let conflict = app.sync_meeting_to_drive(job, false).await?; + assert_eq!( + states(&conflict)[1], + (ArtifactKind::SpeakerText, DriveSyncState::Conflict) + ); + assert_eq!( + port.drive + .remote_checksum(ArtifactKind::SpeakerText)? + .as_deref(), + Some("edited-elsewhere") + ); + + // Overwriting replaces the Drive copy + let overwritten = app.sync_meeting_to_drive(job, true).await?; + assert_eq!( + states(&overwritten)[1], + (ArtifactKind::SpeakerText, DriveSyncState::Updated) + ); + assert_eq!( + port.drive.remote_checksum(ArtifactKind::SpeakerText)?, + Some("다시 고친 전사".to_owned()) + ); + Ok(()) +} + +#[tokio::test] +async fn one_failing_file_does_not_stop_the_others() -> Result<(), AppError> { + // Given + let (port, app) = connected()?; + *lock(&port.drive.failing_kind)? = Some((ArtifactKind::Srt, "DRIVE_STORAGE_FULL")); + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + + // When + let result = app.sync_meeting_to_drive(transcribed.job_id, false).await?; + + // Then + assert_eq!( + states(&result), + [ + ( + ArtifactKind::Srt, + DriveSyncState::Failed { + message: "저장 공간 부족".to_owned() + } + ), + (ArtifactKind::SpeakerText, DriveSyncState::Uploaded), + ] + ); + Ok(()) +} + +#[tokio::test] +async fn an_unreachable_drive_ends_the_whole_sync() -> Result<(), AppError> { + // Given: Drive stops answering at the first upload + let (port, app) = connected()?; + *lock(&port.drive.failing_kind)? = Some((ArtifactKind::Srt, "DRIVE_UNAVAILABLE")); + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + + // When + let error = failure(app.sync_meeting_to_drive(transcribed.job_id, false).await)?; + + // Then: no further upload is attempted + assert_eq!(error.code, "DRIVE_UNAVAILABLE"); + assert_eq!(port.drive.calls()?.created, Vec::new()); + Ok(()) +} + +#[tokio::test] +async fn a_gateway_refusing_the_bearer_at_exchange_signs_the_host_out() -> Result<(), AppError> { + // Given: the gateway answers the Drive code exchange with 401 + let (port, app) = signed_in()?; + lock(&port.drive.exchanges)?.push_back(Err(DriveTokenFailure::AuthRequired)); + + // When + let error = failure(app.connect_drive().await)?; + + // Then: the host closes the session the window is about to leave + assert_eq!(error.code, "AUTH_REQUIRED"); + assert_eq!(port.gateway.stored_tokens()?, None); + assert_eq!( + failure(app.transcribe(request(SpeakerHint::Auto)).await)?.code, + "AUTH_REQUIRED" + ); + Ok(()) +} + +#[tokio::test] +async fn minutes_sync_under_the_transcription_job_after_refinement() -> Result<(), AppError> { + // Given: a transcribed and refined meeting + let (_port, app) = connected()?; + app.save_assistant_api_key("zai_key".to_owned()).await?; + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + app.sync_meeting_to_drive(transcribed.job_id, false).await?; + app.refine_transcript(Uuid::now_v7(), transcribed.job_id, &[]) + .await?; + + // When + let result = app.sync_meeting_to_drive(transcribed.job_id, false).await?; + + // Then + assert_eq!( + states(&result)[2], + (ArtifactKind::Minutes, DriveSyncState::Uploaded) + ); + Ok(()) +} + +#[tokio::test] +async fn an_imported_transcript_is_a_meeting_too() -> Result<(), AppError> { + // Given + let (_port, app) = connected()?; + let imported = app + .import_transcript(TranscriptImportRequest { + job_id: Uuid::now_v7(), + input_path: "/tmp/notes.txt".to_owned(), + output_root: "/tmp/output".to_owned(), + }) + .await?; + + // When + let result = app.sync_meeting_to_drive(imported.job_id, false).await?; + + // Then + assert_eq!( + states(&result), + [(ArtifactKind::SpeakerText, DriveSyncState::Uploaded)] + ); + Ok(()) +} + +#[tokio::test] +async fn a_refused_renewal_disconnects_drive() -> Result<(), AppError> { + // Given: Drive tokens about to expire, and Google refuses to renew them + let (port, app) = connected()?; + port.drive.stored()?.tokens = Some(drive_tokens(&port, "stored", 60)); + port.drive + .script_refresh(Err(DriveTokenFailure::Rejected))?; + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + + // When + let error = failure(app.sync_meeting_to_drive(transcribed.job_id, false).await)?; + + // Then + assert_eq!(error.code, "DRIVE_RECONNECT_REQUIRED"); + assert_eq!(port.drive.stored()?.connection, None); + assert_eq!(stored_tokens(&port)?, None); + assert_eq!( + port.drive.calls()?.refresh_bearers, + ["galpi-test-access-session"] + ); + Ok(()) +} + +#[tokio::test] +async fn a_drive_401_renews_once_and_never_resends() -> Result<(), AppError> { + // Given: Drive refuses the stored token, but renewal works + let (port, app) = connected()?; + port.drive.reject_files.store(true, Ordering::Relaxed); + port.drive + .script_refresh(Ok(drive_tokens(&port, "renewed", 3_600)))?; + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + + // When + let renewed = failure(app.sync_meeting_to_drive(transcribed.job_id, false).await)?; + + // Then: the renewal is kept, and the refused request is not retried + assert_eq!(renewed.code, "DRIVE_UNAVAILABLE"); + assert_eq!(port.drive.calls()?.refresh_bearers.len(), 1); + assert_eq!(port.drive.calls()?.created, Vec::new()); + assert_eq!( + stored_tokens(&port)?.map(|tokens| tokens.access_token), + Some("galpi-test-drive-access-renewed".to_owned()) + ); + + // Given: the renewal is refused too + port.drive + .script_refresh(Err(DriveTokenFailure::Rejected))?; + + // When + let refused = failure(app.sync_meeting_to_drive(transcribed.job_id, false).await)?; + + // Then + assert_eq!(refused.code, "DRIVE_RECONNECT_REQUIRED"); + assert_eq!(port.drive.stored()?.connection, None); + Ok(()) +} + +#[tokio::test] +async fn a_deleted_galpi_folder_is_recreated_and_remembered() -> Result<(), AppError> { + // Given: the remembered folder is gone from Drive + let (port, app) = connected()?; + port.drive.remote()?.root = None; + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + + // When + app.sync_meeting_to_drive(transcribed.job_id, false).await?; + + // Then + let root = port.drive.remote()?.root.clone(); + assert!(root.is_some()); + assert_ne!(root.as_deref(), Some("root-saved")); + assert_eq!( + port.drive + .stored()? + .connection + .as_ref() + .map(|connection| connection.folder_id.clone()), + root + ); + Ok(()) +} + +#[tokio::test] +async fn disconnecting_revokes_and_forgets_but_keeps_the_onboarding_choice() -> Result<(), AppError> +{ + // Given + let (port, app) = connected()?; + app.dismiss_drive_onboarding().await?; + + // When + let status = app.disconnect_drive().await?; + + // Then + assert_eq!( + status, + DriveStatus::Disconnected { + onboarding_dismissed: true + } + ); + assert_eq!( + port.drive.calls()?.revoked, + ["galpi-test-drive-refresh-stored"] + ); + assert_eq!(stored_tokens(&port)?, None); + assert_eq!(port.drive.stored()?.connection, None); + Ok(()) +} + +#[tokio::test] +async fn disconnecting_during_a_picker_consent_keeps_the_store_empty() -> Result<(), AppError> { + // Given: an import waiting for the Picker + let (port, app) = connected()?; + port.drive.push_consent(Consent::Wait)?; + let app = Arc::new(app); + let importing = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.import_from_drive(Uuid::now_v7()).await }) + }; + port.drive.consent_started.notified().await; + + // When: Drive is disconnected, then the Picker comes back + app.disconnect_drive().await?; + port.drive.consent_release.notify_one(); + let error = failure(joined(importing.await)?)?; + + // Then: the new grant is given back, nothing comes back to life + assert_eq!(error.code, "DRIVE_NOT_CONNECTED"); + assert_eq!(stored_tokens(&port)?, None); + assert_eq!(port.drive.stored()?.connection, None); + assert_eq!( + port.drive.calls()?.revoked, + [ + "galpi-test-drive-refresh-stored", + "galpi-test-drive-refresh-exchanged" + ] + ); + Ok(()) +} + +#[tokio::test] +async fn reconnecting_another_account_during_a_picker_consent_keeps_its_tokens() +-> Result<(), AppError> { + // Given: an import for account A waiting for the Picker + let (port, app) = connected()?; + port.drive.push_consent(Consent::Wait)?; + let app = Arc::new(app); + let importing = { + let app = Arc::clone(&app); + tokio::spawn(async move { app.import_from_drive(Uuid::now_v7()).await }) + }; + port.drive.consent_started.notified().await; + + // When: Drive is reconnected to account B, then A's Picker comes back + { + let mut stored = port.drive.stored()?; + if let Some(connection) = stored.connection.as_mut() { + connection.email = "other@example.com".to_owned(); + connection.permission_id = "perm-2".to_owned(); + } + stored.tokens = Some(drive_tokens(&port, "other", 3_600)); + } + port.drive.consent_release.notify_one(); + let error = failure(joined(importing.await)?)?; + + // Then: A's grant is given back and B keeps its own tokens + assert_eq!(error.code, "DRIVE_NOT_CONNECTED"); + assert_eq!( + stored_tokens(&port)?, + Some(drive_tokens(&port, "other", 3_600)) + ); + assert_eq!( + port.drive.calls()?.revoked, + ["galpi-test-drive-refresh-exchanged"] + ); + Ok(()) +} + +#[tokio::test] +async fn a_gateway_refusal_during_a_drive_call_signs_the_app_out() -> Result<(), AppError> { + // Given: an expired gateway session the gateway refuses to renew + let (port, app) = connected()?; + port.gateway + .seed(tokens_expiring(&port, "expired", 0), None)?; + port.gateway + .script_refresh(Err(GatewayRefreshFailure::Rejected))?; + port.drive.stored()?.tokens = Some(drive_tokens(&port, "stored", 0)); + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + + // When + let error = failure(app.sync_meeting_to_drive(transcribed.job_id, false).await)?; + + // Then: Drive stays connected, but the app is closed until sign-in + assert_eq!(error.code, "AUTH_REQUIRED"); + assert_eq!(port.gateway.stored_tokens()?, None); + assert!(port.drive.stored()?.connection.is_some()); + assert_eq!( + failure(app.transcribe(request(SpeakerHint::Auto)).await)?.code, + "AUTH_REQUIRED" + ); + Ok(()) +} + +#[tokio::test] +async fn syncing_without_a_connection_is_refused() -> Result<(), AppError> { + // Given + let (_port, app) = signed_in()?; + let transcribed = app.transcribe(request(SpeakerHint::Auto)).await?; + + // When + let error = failure(app.sync_meeting_to_drive(transcribed.job_id, false).await)?; + + // Then + assert_eq!(error.code, "DRIVE_NOT_CONNECTED"); + Ok(()) +} diff --git a/src-tauri/src/application/tests/gateway.rs b/src-tauri/src/application/tests/gateway.rs index d45e0d1..658a999 100644 --- a/src-tauri/src/application/tests/gateway.rs +++ b/src-tauri/src/application/tests/gateway.rs @@ -1,6 +1,6 @@ use super::{FakePort, TranscriptionBehavior, request}; use crate::application::error::AppError; -use crate::application::ports::{GatewayAuthPort, GatewaySessionStore}; +use crate::application::ports::{ClockPort, GatewayAuthPort, GatewaySessionStore}; use crate::application::use_cases::Application; use crate::domain::gateway::{AppAccess, GatewayGrant, GatewayRefreshFailure, GatewayTokens}; use crate::domain::job::{SetupRequest, SpeakerHint, TranscriptImportRequest}; @@ -53,14 +53,14 @@ impl GatewayFake { lock(&self.calls) } - fn seed(&self, tokens: GatewayTokens, email: Option<&str>) -> Result<(), AppError> { + pub(super) fn seed(&self, tokens: GatewayTokens, email: Option<&str>) -> Result<(), AppError> { let mut stored = self.stored()?; stored.tokens = Some(tokens); stored.email = email.map(str::to_owned); Ok(()) } - fn script_refresh( + pub(super) fn script_refresh( &self, result: Result, ) -> Result<(), AppError> { @@ -72,6 +72,10 @@ impl GatewayFake { *lock(&self.sign_in_result)? = Some(result); Ok(()) } + + pub(super) fn stored_tokens(&self) -> Result, AppError> { + Ok(self.stored()?.tokens.clone()) + } } #[async_trait] @@ -154,6 +158,21 @@ fn tokens(refresh: &str) -> GatewayTokens { } } +/// Tokens that expire `seconds_left` after the fake clock's now. +pub(super) fn tokens_expiring(port: &FakePort, refresh: &str, seconds_left: u64) -> GatewayTokens { + GatewayTokens { + expires_at: port.now_unix() + seconds_left, + ..tokens(refresh) + } +} + +fn signed_in() -> AppAccess { + AppAccess::SignedIn { + email: None, + offline: false, + } +} + fn port() -> Arc { Arc::new(FakePort::new(TranscriptionBehavior::Success)) } @@ -481,3 +500,100 @@ async fn gateway_cancelled_sign_in_keeps_the_stored_session() -> Result<(), AppE app.transcribe(request(SpeakerHint::Auto)).await?; Ok(()) } + +#[tokio::test] +async fn gateway_bearer_reuses_a_token_that_is_not_about_to_expire() -> Result<(), AppError> { + // Given + let port = port(); + port.gateway + .seed(tokens_expiring(&port, "old", 3_600), None)?; + let gate = port.gate(signed_in()); + + // When + let bearer = gate.bearer().await?; + + // Then + assert_eq!(bearer, "galpi-test-access-old"); + assert_eq!(port.gateway.calls()?.refresh_tokens, Vec::::new()); + Ok(()) +} + +#[tokio::test] +async fn gateway_bearer_renews_within_a_minute_of_expiry_and_stores_the_rotation() +-> Result<(), AppError> { + // Given: a token with thirty seconds left + let port = port(); + port.gateway.seed(tokens_expiring(&port, "old", 30), None)?; + let rotated = tokens_expiring(&port, "rotated", 3_600); + port.gateway.script_refresh(Ok(rotated.clone()))?; + let gate = port.gate(signed_in()); + + // When + let bearer = gate.bearer().await?; + + // Then + assert_eq!(bearer, "galpi-test-access-rotated"); + assert_eq!( + port.gateway.calls()?.refresh_tokens, + ["galpi-test-refresh-old"] + ); + assert_eq!(port.gateway.stored()?.tokens, Some(rotated)); + Ok(()) +} + +#[tokio::test] +async fn gateway_bearer_refusal_forgets_the_session_and_closes_the_app() -> Result<(), AppError> { + // Given + let port = port(); + port.gateway.seed(tokens_expiring(&port, "old", 0), None)?; + port.gateway + .script_refresh(Err(GatewayRefreshFailure::Rejected))?; + let gate = port.gate(signed_in()); + + // When + let error = failure(gate.bearer().await)?; + + // Then + assert_eq!(error.code, "AUTH_REQUIRED"); + assert_eq!(port.gateway.stored()?.tokens, None); + assert_eq!( + gate.require().err().map(|error| error.code).as_deref(), + Some("AUTH_REQUIRED") + ); + Ok(()) +} + +#[tokio::test] +async fn gateway_bearer_unreachable_keeps_the_session_and_the_app_open() -> Result<(), AppError> { + // Given + let port = port(); + let stored = tokens_expiring(&port, "old", 0); + port.gateway.seed(stored.clone(), None)?; + port.gateway + .script_refresh(Err(GatewayRefreshFailure::Unavailable))?; + let gate = port.gate(signed_in()); + + // When + let error = failure(gate.bearer().await)?; + + // Then + assert_eq!(error.code, "GATEWAY_UNAVAILABLE"); + assert_eq!(port.gateway.stored()?.tokens, Some(stored)); + gate.require()?; + Ok(()) +} + +#[tokio::test] +async fn gateway_bearer_without_a_stored_session_requires_sign_in() -> Result<(), AppError> { + // Given: an open gate whose session is gone from the store + let port = port(); + let gate = port.gate(signed_in()); + + // When + let error = failure(gate.bearer().await)?; + + // Then + assert_eq!(error.code, "AUTH_REQUIRED"); + assert_eq!(port.gateway.calls()?.refresh_tokens, Vec::::new()); + Ok(()) +} diff --git a/src-tauri/src/application/use_cases.rs b/src-tauri/src/application/use_cases.rs index 8aaad31..d0edabb 100644 --- a/src-tauri/src/application/use_cases.rs +++ b/src-tauri/src/application/use_cases.rs @@ -1,4 +1,5 @@ use crate::application::chatgpt::ChatGptAccounts; +use crate::application::drive::DriveAccounts; use crate::application::error::AppError; use crate::application::gateway::AppAccessGate; use crate::application::jobs::JobRegistry; @@ -15,6 +16,9 @@ use crate::domain::chatgpt::{ AssistantAuthMode, AssistantTransport, ChatGptModel, ChatGptPreferences, ChatGptSettings, SignOutResult, }; +use crate::domain::drive::{ + DriveFileId, DriveImport, DriveMeeting, DriveStatus, DriveSyncResult, MeetingKey, +}; use crate::domain::engine::{ComputeDevice, EnginePreset, EngineSelection}; use crate::domain::gateway::AppAccess; use crate::domain::job::{ @@ -22,8 +26,9 @@ use crate::domain::job::{ }; use crate::domain::roster::{AssistantSettings, GlossaryEntry, Participant}; use crate::domain::worker::AsrContext; -use std::path::Path; -use std::sync::Arc; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; use uuid::Uuid; /// The credential and endpoint settings one refinement runs with. @@ -45,8 +50,16 @@ pub struct Application { refinement: Arc, chatgpt: ChatGptAccounts, access: AppAccessGate, + drive: DriveAccounts, jobs: JobRegistry, active_recording: tokio::sync::Mutex>, + /// Where each finished meeting goes on Drive, by its transcription or + /// transcript-import job id. Refinement registers its minutes under that + /// same id, so a sync after refinement finds them here too. + drive_meetings: Mutex>, + /// Every recording downloaded from Drive, so a transcription that claims + /// a Drive source is checked against what was actually imported. + drive_imports: Mutex>, } impl Application { @@ -62,6 +75,7 @@ impl Application { refinement: Arc, chatgpt: ChatGptAccounts, access: AppAccessGate, + drive: DriveAccounts, ) -> Self { Self { engine, @@ -73,8 +87,11 @@ impl Application { refinement, chatgpt, access, + drive, jobs: JobRegistry::default(), active_recording: tokio::sync::Mutex::new(None), + drive_meetings: Mutex::new(HashMap::new()), + drive_imports: Mutex::new(HashMap::new()), } } @@ -289,6 +306,96 @@ impl Application { self.chatgpt.sign_out().await } + pub async fn load_drive_status(&self) -> Result { + self.drive.status().await + } + + pub async fn dismiss_drive_onboarding(&self) -> Result { + self.drive.dismiss_onboarding().await + } + + /// Resolves when the browser consent finishes, is cancelled, or times out. + pub async fn connect_drive(&self) -> Result { + self.access.require()?; + self.drive.connect(&self.access).await + } + + pub fn cancel_drive_operation(&self) -> Result<(), AppError> { + self.drive.cancel() + } + + pub async fn disconnect_drive(&self) -> Result { + self.drive.disconnect().await + } + + /// Pick one recording in the desktop Picker and download it as job `job_id`, + /// so the job's cancel stops both the consent wait and the download. + pub async fn import_from_drive(&self, job_id: Uuid) -> Result { + self.access.require()?; + let (job, mut cancel) = self.jobs.claim_with_id(job_id)?; + let job_id = job.id(); + let imported = self.drive.import(&self.access, &mut cancel).await?; + self.drive_imports + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(imported.path.clone(), imported.id.clone()); + Ok(DriveImport { + job_id, + input_path: imported.path.to_string_lossy().into_owned(), + drive_file_id: imported.id, + name: imported.name, + }) + } + + /// Upload or update a finished meeting's results on Drive. `job_id` is the + /// transcription or transcript-import job; refinement adds its minutes there. + pub async fn sync_meeting_to_drive( + &self, + job_id: Uuid, + overwrite: bool, + ) -> Result { + self.access.require()?; + let artifacts = self.jobs.artifacts(job_id)?; + let meeting = self + .drive_meetings + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(&job_id) + .cloned() + .ok_or_else(|| AppError::new("ARTIFACT_NOT_FOUND", "완료된 결과를 찾지 못했습니다."))?; + self.drive + .sync(&self.access, &artifacts, &meeting, overwrite) + .await + } + + fn remember_meeting(&self, job_id: Uuid, key: MeetingKey, source: &Path) { + self.drive_meetings + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(job_id, DriveMeeting::new(key, source)); + } + + /// The Drive id a transcription claims, accepted only for the very file + /// that id was imported to. + fn imported_drive_source(&self, input_path: &str, id: &str) -> Result { + let mismatch = || { + AppError::new( + "DRIVE_SOURCE_MISMATCH", + "Drive에서 가져온 녹음 정보가 맞지 않습니다. 다시 가져와 주세요.", + ) + }; + let id = DriveFileId::parse(id).map_err(|_| mismatch())?; + let imports = self + .drive_imports + .lock() + .unwrap_or_else(PoisonError::into_inner); + if imports.get(Path::new(input_path)) == Some(&id) { + Ok(id) + } else { + Err(mismatch()) + } + } + pub async fn transcribe( &self, request: TranscriptionRequest, @@ -296,9 +403,23 @@ impl Application { self.access.require()?; validate_speaker_hint(&request.speaker_hint) .map_err(|error| AppError::new("INVALID_SPEAKER_HINT", error.to_string()))?; + let drive_source = request + .drive_source_id + .as_deref() + .map(|id| self.imported_drive_source(&request.input_path, id)) + .transpose()?; let (job, mut cancel) = self.jobs.claim_with_id(request.job_id)?; - self.run_transcription(job.id(), &mut cancel, &request) - .await + let job_id = job.id(); + let result = self + .run_transcription(job_id, &mut cancel, &request) + .await?; + if let Some(source) = self.jobs.artifacts(job_id)?.source_audio { + let key = drive_source + .as_ref() + .map_or_else(|| MeetingKey::local(&source), MeetingKey::drive); + self.remember_meeting(job_id, key, &source); + } + Ok(result) } /// Register an existing transcript file as a finished meeting so it can be @@ -319,6 +440,9 @@ impl Application { .await; let artifacts = result?; self.jobs.register(job_id, artifacts.clone())?; + if let Some(source) = &artifacts.source_transcript { + self.remember_meeting(job_id, MeetingKey::local(source), source); + } Ok(TranscriptImportResult { job_id, txt: artifacts.txt.to_string_lossy().into_owned(), diff --git a/src-tauri/src/composition.rs b/src-tauri/src/composition.rs index ff063f2..16f9c33 100644 --- a/src-tauri/src/composition.rs +++ b/src-tauri/src/composition.rs @@ -2,6 +2,7 @@ use crate::adapters::inbound::tauri::TauriEvents; use crate::adapters::outbound::browser_sign_in::TauriBrowser; use crate::adapters::outbound::chatgpt::{ChatGptOAuthAdapter, SystemClock}; use crate::adapters::outbound::desktop::DesktopAdapter; +use crate::adapters::outbound::drive::{DriveOAuthAdapter, DriveRestAdapter}; use crate::adapters::outbound::gateway::GatewayOAuthAdapter; use crate::adapters::outbound::recording::NativeRecorder; #[cfg(windows)] @@ -11,11 +12,13 @@ use crate::adapters::outbound::secrets::SecretStore; use crate::adapters::outbound::secrets::SettingsFile; use crate::adapters::outbound::settings::LocalSettingsStore; use crate::application::chatgpt::ChatGptAccounts; +use crate::application::drive::DriveAccounts; use crate::application::gateway::AppAccessGate; use crate::application::ports::{ - ArtifactPort, ChatGptAuthPort, ChatGptEvents, ChatGptStore, ClockPort, EnginePort, - GatewayAuthPort, GatewaySessionStore, JobEvents, RecordingEvents, RecordingPort, - RefinementPort, SettingsPort, TranscriptImportPort, TranscriptionPort, + ArtifactPort, ChatGptAuthPort, ChatGptEvents, ChatGptStore, ClockPort, DriveAuthPort, + DriveFilesPort, DriveSessionStore, EnginePort, GatewayAuthPort, GatewaySessionStore, JobEvents, + RecordingEvents, RecordingPort, RefinementPort, SettingsPort, TranscriptImportPort, + TranscriptionPort, }; use crate::application::use_cases::Application; use std::sync::Arc; @@ -48,7 +51,8 @@ pub fn run() { let local_settings = Arc::new(LocalSettingsStore::new(app.handle(), secrets)?); let settings: Arc = local_settings.clone(); let chatgpt_store: Arc = local_settings.clone(); - let gateway_store: Arc = local_settings; + let gateway_store: Arc = local_settings.clone(); + let drive_store: Arc = local_settings; let clock: Arc = Arc::new(SystemClock); let browser = Arc::new(TauriBrowser::new(app.handle().clone())); let chatgpt_auth: Arc = Arc::new(ChatGptOAuthAdapter::new( @@ -57,7 +61,15 @@ pub fn run() { clock.clone(), )?); let gateway_auth: Arc = - Arc::new(GatewayOAuthAdapter::new(browser, clock.clone())?); + Arc::new(GatewayOAuthAdapter::new(browser.clone(), clock.clone())?); + let drive_auth: Arc = + Arc::new(DriveOAuthAdapter::new(browser, clock.clone())?); + let drive_rest = DriveRestAdapter::new(app.handle())?; + // Downloads from an earlier run are never needed again. + if let Err(error) = drive_rest.clear_imports() { + eprintln!("failed to clear Drive imports: {error}"); + } + let drive_files: Arc = Arc::new(drive_rest); app.manage(Application::new( engine, transcription, @@ -66,8 +78,9 @@ pub fn run() { recording, settings, refinement, - ChatGptAccounts::new(chatgpt_auth, chatgpt_store, clock), - AppAccessGate::new(gateway_auth, gateway_store), + ChatGptAccounts::new(chatgpt_auth, chatgpt_store, clock.clone()), + AppAccessGate::new(gateway_auth, gateway_store, clock.clone()), + DriveAccounts::new(drive_auth, drive_files, drive_store, clock), )); Ok(()) }) @@ -100,6 +113,13 @@ pub fn run() { crate::adapters::inbound::tauri::sign_in_to_gateway, crate::adapters::inbound::tauri::cancel_gateway_sign_in, crate::adapters::inbound::tauri::sign_out_of_gateway, + crate::adapters::inbound::tauri::load_drive_status, + crate::adapters::inbound::tauri::connect_drive, + crate::adapters::inbound::tauri::cancel_drive_operation, + crate::adapters::inbound::tauri::disconnect_drive, + crate::adapters::inbound::tauri::dismiss_drive_onboarding, + crate::adapters::inbound::tauri::import_from_drive, + crate::adapters::inbound::tauri::sync_meeting_to_drive, ]) .run(tauri::generate_context!()) .unwrap_or_else(|error| { diff --git a/src-tauri/src/domain/artifact.rs b/src-tauri/src/domain/artifact.rs index c6fa667..985665a 100644 --- a/src-tauri/src/domain/artifact.rs +++ b/src-tauri/src/domain/artifact.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] @@ -14,9 +14,14 @@ pub struct Artifacts { /// whose timestamp is the day the meeting happened rather than the day the /// transcript was written. An imported transcript has no audio. pub source_audio: Option, + /// The transcript file an import copied from, when the meeting was imported. + /// + /// Together with `source_audio` it names the meeting's original file, which + /// stays the same however many output folders the meeting is written to. + pub source_transcript: Option, } -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ArtifactKind { Srt, @@ -86,6 +91,7 @@ mod tests { minutes: None, output_directory: PathBuf::from("/tmp/job"), source_audio: None, + source_transcript: Some(PathBuf::from("/tmp/notes.txt")), }; // When / Then: only the transcript itself is addressable diff --git a/src-tauri/src/domain/drive.rs b/src-tauri/src/domain/drive.rs new file mode 100644 index 0000000..6b0e604 --- /dev/null +++ b/src-tauri/src/domain/drive.rs @@ -0,0 +1,440 @@ +//! Google Drive: the stored connection, file references, the identity of a +//! meeting on Drive, and the stateless decision of what one sync does. +//! +//! `DriveTokens` is secret and never travels in any IPC type: its `Debug` +//! hides every value and only the secret store serializes it. Everything the +//! window sees (`DriveStatus`, `DriveImport`, `DriveSyncResult`) holds no token. + +use crate::domain::artifact::ArtifactKind; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Debug, Formatter}; +use std::path::Path; +use thiserror::Error; +use uuid::Uuid; + +/// Refresh the Drive access token once less than this many seconds remain. +pub const DRIVE_ACCESS_REFRESH_MARGIN_SECONDS: u64 = 5 * 60; + +/// The `appProperties` key that carries a meeting key. +pub const MEETING_PROPERTY: &str = "galpiMeeting"; + +/// Drive limits one `appProperties` entry to 124 bytes of key plus value. +const APP_PROPERTY_BYTES: usize = 124; + +/// The Google OAuth tokens for `drive.file`. Serialized only by the secret store. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DriveTokens { + pub access_token: String, + pub refresh_token: String, + /// Unix seconds. + pub expires_at: u64, +} + +impl DriveTokens { + pub fn needs_refresh(&self, now: u64, margin: u64) -> bool { + now >= self.expires_at.saturating_sub(margin) + } +} + +impl Debug for DriveTokens { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DriveTokens") + .field("access_token", &"") + .field("refresh_token", &"") + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// Why exchanging or renewing Drive tokens through the gateway failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DriveTokenFailure { + /// Google refused the code or refresh token (`invalid_grant`). + Rejected, + /// The gateway refused the Galpi session that vouches for the request. + AuthRequired, + /// Anything else: network, gateway, Google, or an unexpected answer. + Unavailable, +} + +/// Who the stored Drive grant belongs to and where Galpi keeps its files. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DriveConnection { + pub email: String, + /// Google's stable id for the account, used to match a later Picker grant. + pub permission_id: String, + pub folder_id: String, +} + +/// The Google account a token belongs to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DriveAccount { + pub email: String, + pub permission_id: String, +} + +/// What the window shows about Drive. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum DriveStatus { + #[serde(rename_all = "camelCase")] + Disconnected { + onboarding_dismissed: bool, + }, + Connected { + email: String, + }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum DriveIdError { + #[error("Drive file id must be 1-200 characters of A-Z, a-z, 0-9, '-' or '_'")] + Invalid, +} + +/// A Drive file id. Only URL- and query-safe characters are accepted, so an id +/// can be placed in a request path or a `q` expression without escaping. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct DriveFileId(String); + +impl DriveFileId { + pub fn parse(value: &str) -> Result { + let valid = (1..=200).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'); + if valid { + Ok(Self(value.to_owned())) + } else { + Err(DriveIdError::Invalid) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// The Picker callback's comma-separated `picked_file_ids`; empty means none. +pub fn parse_picked_ids(value: &str) -> Result, DriveIdError> { + if value.trim().is_empty() { + return Ok(Vec::new()); + } + value + .split(',') + .map(|id| DriveFileId::parse(id.trim())) + .collect() +} + +/// FNV-1a 64 as 16 hex digits. Unlike std's hasher it never changes between +/// builds or platforms, so a key written to Drive today still matches later. +fn stable_hash(bytes: &[u8]) -> String { + let hash = bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }); + format!("{hash:016x}") +} + +/// Which Drive meeting folder a local meeting belongs to. +/// +/// A recording imported from Drive is keyed by its Drive file id; anything +/// else by its canonical source path. The output folder is never part of the +/// key: transcribing the same file again writes `{stem} 2`, and that must +/// still land in the same Drive folder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeetingKey(String); + +impl MeetingKey { + pub fn drive(id: &DriveFileId) -> Self { + let key = format!("drive:{}", id.as_str()); + if MEETING_PROPERTY.len() + key.len() <= APP_PROPERTY_BYTES { + Self(key) + } else { + Self(format!("drive:{}", stable_hash(id.as_str().as_bytes()))) + } + } + + pub fn local(source: &Path) -> Self { + Self(format!( + "local:{}", + stable_hash(source.as_os_str().as_encoded_bytes()) + )) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A meeting that can be synced: its key and the Drive folder name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DriveMeeting { + pub key: MeetingKey, + pub name: String, +} + +impl DriveMeeting { + /// Keyed as described on [`MeetingKey`], named after the source's stem. + pub fn new(key: MeetingKey, source: &Path) -> Self { + let name = source + .file_stem() + .map(|stem| stem.to_string_lossy().trim().to_owned()) + .filter(|stem| !stem.is_empty()) + .unwrap_or_else(|| "meeting".to_owned()); + Self { key, name } + } +} + +/// A Galpi result file already on Drive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteFile { + pub id: String, + /// What Drive reports for the current content; absent for some files. + pub sha256_checksum: Option, + /// The content checksum Galpi recorded (`galpiSha256`) on its last write. + pub synced_sha256: Option, + pub web_view_link: Option, +} + +/// What one sync does with one result file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncDecision { + Create, + Unchanged, + Update, + /// The Drive copy changed since Galpi last wrote it; leave it alone. + Conflict, +} + +/// Decide from the Drive copy alone, so no local sync record is needed. +/// +/// Galpi records the checksum of what it uploaded in `galpiSha256`. When Drive +/// still reports that checksum, nobody else touched the file; when it reports +/// something else, or nothing at all, someone may have, and only an explicit +/// `overwrite` replaces it. +pub fn decide(remote: Option<&RemoteFile>, local_sha256: &str, overwrite: bool) -> SyncDecision { + let Some(remote) = remote else { + return SyncDecision::Create; + }; + match (&remote.sha256_checksum, &remote.synced_sha256) { + (Some(current), Some(synced)) if current == synced => { + if current == local_sha256 { + SyncDecision::Unchanged + } else { + SyncDecision::Update + } + } + _ if overwrite => SyncDecision::Update, + _ => SyncDecision::Conflict, + } +} + +/// How one result file fared in a sync. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum DriveSyncState { + Uploaded, + Updated, + Unchanged, + Conflict, + Failed { message: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DriveSyncFile { + pub kind: ArtifactKind, + #[serde(flatten)] + pub state: DriveSyncState, + pub web_view_link: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DriveSyncResult { + pub folder_link: Option, + pub files: Vec, +} + +/// A recording downloaded from Drive, ready to transcribe from `input_path`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DriveImport { + pub job_id: Uuid, + pub input_path: String, + pub drive_file_id: DriveFileId, + pub name: String, +} + +#[cfg(test)] +mod tests { + use super::{ + APP_PROPERTY_BYTES, DriveFileId, DriveIdError, DriveMeeting, DriveStatus, DriveSyncFile, + DriveSyncState, DriveTokens, MEETING_PROPERTY, MeetingKey, RemoteFile, SyncDecision, + decide, parse_picked_ids, stable_hash, + }; + use crate::domain::artifact::ArtifactKind; + use serde_json::json; + use std::path::Path; + + const LOCAL: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const SYNCED: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + const OTHER: &str = "3333333333333333333333333333333333333333333333333333333333333333"; + + fn remote(current: Option<&str>, synced: Option<&str>) -> RemoteFile { + RemoteFile { + id: "file-1".to_owned(), + sha256_checksum: current.map(str::to_owned), + synced_sha256: synced.map(str::to_owned), + web_view_link: None, + } + } + + #[test] + fn a_missing_drive_copy_is_created() { + assert_eq!(decide(None, LOCAL, false), SyncDecision::Create); + } + + #[test] + fn an_untouched_drive_copy_with_the_same_content_is_left_unchanged() { + let untouched = remote(Some(LOCAL), Some(LOCAL)); + assert_eq!( + decide(Some(&untouched), LOCAL, false), + SyncDecision::Unchanged + ); + assert_eq!( + decide(Some(&untouched), LOCAL, true), + SyncDecision::Unchanged + ); + } + + #[test] + fn an_untouched_drive_copy_is_updated_when_only_the_local_file_changed() { + let untouched = remote(Some(SYNCED), Some(SYNCED)); + assert_eq!(decide(Some(&untouched), LOCAL, false), SyncDecision::Update); + } + + #[test] + fn a_drive_copy_changed_elsewhere_is_a_conflict_unless_overwritten() { + let edited = remote(Some(OTHER), Some(SYNCED)); + assert_eq!(decide(Some(&edited), LOCAL, false), SyncDecision::Conflict); + assert_eq!(decide(Some(&edited), LOCAL, true), SyncDecision::Update); + } + + #[test] + fn a_drive_copy_without_a_checksum_cannot_be_trusted() { + let unknown = remote(None, Some(SYNCED)); + let unrecorded = remote(Some(SYNCED), None); + assert_eq!(decide(Some(&unknown), LOCAL, false), SyncDecision::Conflict); + assert_eq!( + decide(Some(&unrecorded), LOCAL, false), + SyncDecision::Conflict + ); + assert_eq!(decide(Some(&unknown), LOCAL, true), SyncDecision::Update); + } + + #[test] + fn file_ids_accept_only_short_query_safe_values() { + assert!(DriveFileId::parse("1AbC-d_9").is_ok()); + assert!(DriveFileId::parse(&"a".repeat(200)).is_ok()); + for invalid in ["", "a'b", "a/b", "a b", "a\\b"] { + assert_eq!(DriveFileId::parse(invalid), Err(DriveIdError::Invalid)); + } + assert_eq!( + DriveFileId::parse(&"a".repeat(201)), + Err(DriveIdError::Invalid) + ); + } + + #[test] + fn picked_ids_are_parsed_from_the_callback_list() -> Result<(), DriveIdError> { + assert_eq!(parse_picked_ids("")?, Vec::new()); + let picked = parse_picked_ids("abc,def-1")?; + assert_eq!( + picked.iter().map(DriveFileId::as_str).collect::>(), + vec!["abc", "def-1"] + ); + assert_eq!(parse_picked_ids("abc,../x"), Err(DriveIdError::Invalid)); + Ok(()) + } + + #[test] + fn meeting_keys_fit_one_app_property() -> Result<(), DriveIdError> { + let local = MeetingKey::local(Path::new("/Users/me/녹음/회의.m4a")); + let drive = MeetingKey::drive(&DriveFileId::parse("1AbC")?); + let long = MeetingKey::drive(&DriveFileId::parse(&"x".repeat(200))?); + + assert!(local.as_str().starts_with("local:")); + assert_eq!(drive.as_str(), "drive:1AbC"); + assert!(long.as_str().starts_with("drive:")); + for key in [&local, &drive, &long] { + assert!(MEETING_PROPERTY.len() + key.as_str().len() <= APP_PROPERTY_BYTES); + } + assert_eq!( + local, + MeetingKey::local(Path::new("/Users/me/녹음/회의.m4a")) + ); + assert_ne!( + local, + MeetingKey::local(Path::new("/Users/me/녹음/다른.m4a")) + ); + Ok(()) + } + + #[test] + fn a_meeting_is_named_after_its_source_stem() { + let meeting = DriveMeeting::new( + MeetingKey::local(Path::new("/tmp/주간 회의.m4a")), + Path::new("/tmp/주간 회의.m4a"), + ); + assert_eq!(meeting.name, "주간 회의"); + } + + #[test] + fn meeting_hashes_match_the_published_fnv_1a_vectors() { + assert_eq!(stable_hash(b""), "cbf29ce484222325"); + assert_eq!(stable_hash(b"a"), "af63dc4c8601ec8c"); + assert_eq!(stable_hash(b"foobar"), "85944171f73967e8"); + } + + #[test] + fn drive_tokens_debug_hides_every_token() { + let tokens = DriveTokens { + access_token: "galpi-test-access".to_owned(), + refresh_token: "galpi-test-refresh".to_owned(), + expires_at: 7, + }; + + let printed = format!("{tokens:?}"); + + assert!(!printed.contains("galpi-test")); + assert!(printed.contains("expires_at: 7")); + } + + #[test] + fn status_and_sync_results_serialize_for_the_window() -> Result<(), serde_json::Error> { + let disconnected = DriveStatus::Disconnected { + onboarding_dismissed: true, + }; + let failed = DriveSyncFile { + kind: ArtifactKind::SpeakerText, + state: DriveSyncState::Failed { + message: "실패".to_owned(), + }, + web_view_link: None, + }; + + assert_eq!( + serde_json::to_value(&disconnected)?, + json!({"state": "disconnected", "onboardingDismissed": true}) + ); + assert_eq!( + serde_json::to_value(&failed)?, + json!({"kind": "speaker_text", "state": "failed", "message": "실패", "webViewLink": null}) + ); + Ok(()) + } +} diff --git a/src-tauri/src/domain/job.rs b/src-tauri/src/domain/job.rs index 70077c4..c826ca0 100644 --- a/src-tauri/src/domain/job.rs +++ b/src-tauri/src/domain/job.rs @@ -26,6 +26,10 @@ pub struct TranscriptionRequest { pub input_path: String, pub output_root: String, pub speaker_hint: SpeakerHint, + /// The Drive file id `input_path` was imported from, so the results are + /// synced into that recording's Drive folder. Absent for local files. + #[serde(default)] + pub drive_source_id: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index f1ed636..9612eb6 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -1,5 +1,6 @@ pub mod artifact; pub mod chatgpt; +pub mod drive; pub mod engine; pub mod gateway; pub mod job; diff --git a/src/adapters/tauri-backend.test.ts b/src/adapters/tauri-backend.test.ts index 528df44..7a0637d 100644 --- a/src/adapters/tauri-backend.test.ts +++ b/src/adapters/tauri-backend.test.ts @@ -1,10 +1,15 @@ import { describe, expect, mock, test } from "bun:test" +import type { DriveSyncResult } from "../domain/drive" + import { appAccessSchema, chatGptModelsSchema, chatGptSettingsSchema, chatGptSignOutSchema, + driveImportSchema, + driveStatusSchema, + driveSyncResultSchema, parseChatGptEvent, toJobEvent, } from "./tauri-backend" @@ -197,3 +202,55 @@ describe("appAccessSchema", () => { expect(appAccessSchema.safeParse({ state: "signedIn", email: null }).success).toBe(false) }) }) + +describe("Drive schemas", () => { + test("read both connection states and refuse a leaked token", () => { + expect(driveStatusSchema.parse({ state: "connected", email: "dev@example.com" })).toEqual({ + state: "connected", + email: "dev@example.com", + }) + expect( + driveStatusSchema.parse({ state: "disconnected", onboardingDismissed: true }), + ).toEqual({ state: "disconnected", onboardingDismissed: true }) + expect( + driveStatusSchema.safeParse({ + state: "connected", + email: "dev@example.com", + accessToken: "secret", + }).success, + ).toBe(false) + }) + + test("read an import and refuse one without the Drive file id", () => { + const imported = { + jobId: "job-1", + inputPath: "/cache/drive-imports/x/회의.m4a", + driveFileId: "file-1", + name: "회의.m4a", + } + expect(driveImportSchema.parse(imported)).toEqual(imported) + expect(driveImportSchema.safeParse({ ...imported, driveFileId: undefined }).success).toBe(false) + }) + + test("read every sync state and require a message only for failures", () => { + const result: DriveSyncResult = { + folderLink: "https://drive.google.com/drive/folders/m1", + files: [ + { kind: "srt", state: "uploaded", webViewLink: null }, + { kind: "speaker_text", state: "conflict", webViewLink: "https://drive.google.com/t" }, + { kind: "minutes", state: "failed", message: "저장 공간 부족", webViewLink: null }, + ], + } + const parsed: DriveSyncResult = driveSyncResultSchema.parse(result) + expect(parsed).toEqual(result) + expect( + driveSyncResultSchema.safeParse({ + folderLink: null, + files: [{ kind: "srt", state: "failed", webViewLink: null }], + }).success, + ).toBe(false) + expect( + driveSyncResultSchema.safeParse({ ...result, refreshToken: "secret" }).success, + ).toBe(false) + }) +}) diff --git a/src/adapters/tauri-backend.ts b/src/adapters/tauri-backend.ts index 3ae2580..5a3e9dd 100644 --- a/src/adapters/tauri-backend.ts +++ b/src/adapters/tauri-backend.ts @@ -32,6 +32,7 @@ import type { ChatGptSignInPhase, ChatGptSignOutResult, } from "../domain/chatgpt" +import type { DriveImport, DriveStatus, DriveSyncResult } from "../domain/drive" const enginePresetSchema = z.enum(["qwen3", "whisperx"]) const computeDeviceSchema = z.enum(["cpu", "cuda"]) @@ -130,6 +131,43 @@ export const appAccessSchema = z.discriminatedUnion("state", [ }), ]) +// Strict like the sign-in schemas: no Drive token may ever reach the window. +export const driveStatusSchema = z.discriminatedUnion("state", [ + z.strictObject({ state: z.literal("disconnected"), onboardingDismissed: z.boolean() }), + z.strictObject({ state: z.literal("connected"), email: z.string() }), +]) + +export const driveImportSchema = z.strictObject({ + jobId: z.string(), + inputPath: z.string(), + driveFileId: z.string(), + name: z.string(), +}) + +const artifactKindSchema = z.enum(["srt", "speaker_text", "checkpoint", "minutes"]) + +export const driveSyncResultSchema = z.strictObject({ + folderLink: z.string().nullable(), + files: z.array( + z.discriminatedUnion("state", [ + z.strictObject({ + kind: artifactKindSchema, + state: z.literal("failed"), + message: z.string(), + webViewLink: z.string().nullable(), + }), + z.strictObject({ + kind: artifactKindSchema, + state: z.enum(["uploaded", "updated", "unchanged", "conflict"]), + webViewLink: z.string().nullable(), + }), + ]), + ), +}) + +/** Only Drive pages may be opened from a sync result. */ +const DRIVE_LINK_PREFIX = "https://drive.google.com/" + const transcriptImportSchema = z.object({ jobId: z.string(), txt: z.string(), @@ -377,6 +415,43 @@ export class TauriBackend implements BackendPort { async signOutOfGateway(): Promise { await invoke("sign_out_of_gateway") } + + async loadDriveStatus(): Promise { + return driveStatusSchema.parse(await invoke("load_drive_status")) + } + + async connectDrive(): Promise { + return driveStatusSchema.parse(await invoke("connect_drive")) + } + + async cancelDriveOperation(): Promise { + await invoke("cancel_drive_operation") + } + + async disconnectDrive(): Promise { + return driveStatusSchema.parse(await invoke("disconnect_drive")) + } + + async dismissDriveOnboarding(): Promise { + return driveStatusSchema.parse(await invoke("dismiss_drive_onboarding")) + } + + async importFromDrive(jobId: string): Promise { + return driveImportSchema.parse(await invoke("import_from_drive", { jobId })) + } + + async syncMeetingToDrive(jobId: string, overwrite: boolean): Promise { + return driveSyncResultSchema.parse( + await invoke("sync_meeting_to_drive", { jobId, overwrite }), + ) + } + + async openDriveLink(url: string): Promise { + if (!url.startsWith(DRIVE_LINK_PREFIX)) { + throw new Error("Drive 주소가 아닌 링크는 열지 않습니다.") + } + await openUrl(url) + } } /** diff --git a/src/application/access-machine.test.ts b/src/application/access-machine.test.ts index 2955f5b..f03ec66 100644 --- a/src/application/access-machine.test.ts +++ b/src/application/access-machine.test.ts @@ -89,6 +89,21 @@ describe("reduceAccess", () => { ) }) + test("a session the host closed sends an open app back to the login screen", () => { + // Given + const open = run([{ type: "loaded", access: signedIn }]) + + // When + const expired = reduceAccess(open, { type: "sessionExpired", message: "만료되었습니다." }) + + // Then + expect(expired).toEqual({ status: "signedOut", error: "만료되었습니다." }) + expect(canSignIn(expired)).toBe(true) + expect(reduceAccess(initialAccess, { type: "sessionExpired", message: "늦음" })).toBe( + initialAccess, + ) + }) + test("outcomes that answer no pending attempt are ignored", () => { const open = run([{ type: "loaded", access: signedIn }]) diff --git a/src/application/access-machine.ts b/src/application/access-machine.ts index 1827baf..fc0c4e2 100644 --- a/src/application/access-machine.ts +++ b/src/application/access-machine.ts @@ -26,6 +26,8 @@ export type AccessEvent = | { readonly type: "signOutStarted" } | { readonly type: "signOutSucceeded" } | { readonly type: "signOutFailed"; readonly message: string } + /** The host refused a feature command with `AUTH_REQUIRED`: its session is gone. */ + | { readonly type: "sessionExpired"; readonly message: string } export const initialAccess: AccessState = { status: "checking" } @@ -66,5 +68,7 @@ export function reduceAccess(state: AccessState, event: AccessEvent): AccessStat return state.status === "signingOut" ? { status: "signedOut", error: null } : state case "signOutFailed": return state.status === "signingOut" ? { status: "signedOut", error: event.message } : state + case "sessionExpired": + return state.status === "signedIn" ? { status: "signedOut", error: event.message } : state } } diff --git a/src/application/drive-machine.test.ts b/src/application/drive-machine.test.ts new file mode 100644 index 0000000..ff09dc0 --- /dev/null +++ b/src/application/drive-machine.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test" + +import type { DriveSyncResult } from "../domain/drive" +import { type DriveEvent, type DriveState, initialDrive, reduceDrive } from "./drive-machine" + +const connected = { state: "connected", email: "dev@example.com" } as const +const disconnected = { state: "disconnected", onboardingDismissed: false } as const +const dismissed = { state: "disconnected", onboardingDismissed: true } as const + +const result: DriveSyncResult = { + folderLink: "https://drive.google.com/drive/folders/m1", + files: [{ kind: "speaker_text", state: "uploaded", webViewLink: null }], +} + +function run(events: readonly DriveEvent[], from: DriveState = initialDrive): DriveState { + return events.reduce(reduceDrive, from) +} + +const offline = run([{ type: "statusLoaded", status: disconnected }]) +const online = run([{ type: "statusLoaded", status: connected }]) + +describe("reduceDrive", () => { + test("the loaded status decides between the onboarding card and the connection", () => { + expect(initialDrive.connection).toEqual({ status: "unknown" }) + expect(offline.connection).toEqual({ status: "disconnected", onboardingDismissed: false }) + expect(online.connection).toEqual({ status: "connected", email: "dev@example.com" }) + }) + + test("connecting succeeds, fails with a reason, or is cancelled back to idle", () => { + // Given + const connecting = reduceDrive(offline, { type: "connectStarted" }) + + // Then + expect(connecting.activity).toBe("connecting") + expect(reduceDrive(connecting, { type: "connectSucceeded", status: connected })).toEqual({ + ...online, + activity: "idle", + }) + expect(reduceDrive(connecting, { type: "connectFailed", message: "실패" })).toEqual({ + ...offline, + message: "실패", + }) + expect(reduceDrive(connecting, { type: "connectCancelled" })).toEqual(offline) + }) + + test("only a disconnected, idle Drive can start connecting", () => { + const connecting = reduceDrive(offline, { type: "connectStarted" }) + + expect(reduceDrive(online, { type: "connectStarted" })).toBe(online) + expect(reduceDrive(initialDrive, { type: "connectStarted" })).toBe(initialDrive) + expect(reduceDrive(connecting, { type: "connectStarted" })).toBe(connecting) + }) + + test("connect outcomes with no attempt waiting are stale", () => { + expect(reduceDrive(offline, { type: "connectSucceeded", status: connected })).toBe(offline) + expect(reduceDrive(offline, { type: "connectFailed", message: "늦음" })).toBe(offline) + expect(reduceDrive(offline, { type: "connectCancelled" })).toBe(offline) + }) + + test("dismissing hides the card, disconnecting forgets the sync, failures are kept", () => { + const synced = run( + [ + { type: "syncStarted", jobId: "job-1" }, + { type: "syncFinished", jobId: "job-1", result }, + ], + online, + ) + + expect(reduceDrive(offline, { type: "onboardingDismissed", status: dismissed }).connection).toEqual( + { status: "disconnected", onboardingDismissed: true }, + ) + expect(reduceDrive(synced, { type: "disconnected", status: dismissed })).toEqual({ + ...initialDrive, + connection: { status: "disconnected", onboardingDismissed: true }, + }) + expect(reduceDrive(online, { type: "failed", message: "지우지 못함" }).message).toBe( + "지우지 못함", + ) + }) + + test("an import runs only while connected and idle, and either outcome ends it", () => { + const importing = reduceDrive(online, { type: "importStarted" }) + + expect(importing.activity).toBe("importing") + expect(reduceDrive(importing, { type: "imported" }).activity).toBe("idle") + expect(reduceDrive(importing, { type: "importFailed" }).activity).toBe("idle") + expect(reduceDrive(offline, { type: "importStarted" })).toBe(offline) + expect(reduceDrive(importing, { type: "importStarted" })).toBe(importing) + }) + + test("a sync reports the latest meeting and drops answers for any other", () => { + // Given + const syncing = reduceDrive(online, { type: "syncStarted", jobId: "job-2" }) + + // Then + expect(syncing.sync).toEqual({ status: "syncing", jobId: "job-2" }) + expect(reduceDrive(syncing, { type: "syncFinished", jobId: "job-2", result }).sync).toEqual({ + status: "done", + jobId: "job-2", + result, + }) + expect(reduceDrive(syncing, { type: "syncFailed", jobId: "job-2", message: "끊김" }).sync).toEqual( + { status: "failed", jobId: "job-2", message: "끊김" }, + ) + expect(reduceDrive(syncing, { type: "syncFinished", jobId: "job-1", result })).toBe(syncing) + expect(reduceDrive(syncing, { type: "syncCleared" }).sync).toBeNull() + expect(reduceDrive(offline, { type: "syncStarted", jobId: "job-2" })).toBe(offline) + }) + + test("a lost connection brings the card back and fails the pending sync", () => { + // Given: a dismissed card and a sync in flight + const syncing = run( + [ + { type: "statusLoaded", status: connected }, + { type: "syncStarted", jobId: "job-1" }, + ], + run([{ type: "statusLoaded", status: dismissed }]), + ) + + // When + const lost = reduceDrive(syncing, { type: "reconnectRequired", message: "다시 연결해 주세요." }) + + // Then + expect(lost).toEqual({ + connection: { status: "disconnected", onboardingDismissed: false }, + activity: "idle", + message: "다시 연결해 주세요.", + sync: { status: "failed", jobId: "job-1", message: "다시 연결해 주세요." }, + }) + }) + + test("signing out forgets everything", () => { + const busy = run([{ type: "importStarted" }, { type: "syncStarted", jobId: "job-1" }], online) + + expect(reduceDrive(busy, { type: "signedOut" })).toEqual(initialDrive) + }) +}) diff --git a/src/application/drive-machine.ts b/src/application/drive-machine.ts new file mode 100644 index 0000000..62632ed --- /dev/null +++ b/src/application/drive-machine.ts @@ -0,0 +1,119 @@ +import type { DriveStatus, DriveSyncResult } from "../domain/drive" + +export type DriveConnection = + | { readonly status: "unknown" } + | { readonly status: "disconnected"; readonly onboardingDismissed: boolean } + | { readonly status: "connected"; readonly email: string } + +/** + * The one Drive action that blocks others. Importing shows the Picker wait + * and the download as one step: nothing tells the window when one ends. + */ +export type DriveActivity = "idle" | "connecting" | "importing" + +/** The latest meeting's sync; an answer for any other job is stale. */ +export type DriveSync = + | { readonly status: "syncing"; readonly jobId: string } + | { readonly status: "done"; readonly jobId: string; readonly result: DriveSyncResult } + | { readonly status: "failed"; readonly jobId: string; readonly message: string } + +export interface DriveState { + readonly connection: DriveConnection + readonly activity: DriveActivity + /** A connect, disconnect, or onboarding failure, or a request to reconnect. */ + readonly message: string | null + readonly sync: DriveSync | null +} + +export type DriveEvent = + | { readonly type: "statusLoaded"; readonly status: DriveStatus } + | { readonly type: "connectStarted" } + | { readonly type: "connectSucceeded"; readonly status: DriveStatus } + | { readonly type: "connectFailed"; readonly message: string } + | { readonly type: "connectCancelled" } + | { readonly type: "onboardingDismissed"; readonly status: DriveStatus } + | { readonly type: "disconnected"; readonly status: DriveStatus } + | { readonly type: "failed"; readonly message: string } + | { readonly type: "importStarted" } + | { readonly type: "imported" } + | { readonly type: "importFailed" } + | { readonly type: "syncStarted"; readonly jobId: string } + | { readonly type: "syncFinished"; readonly jobId: string; readonly result: DriveSyncResult } + | { readonly type: "syncFailed"; readonly jobId: string; readonly message: string } + | { readonly type: "syncCleared" } + | { readonly type: "reconnectRequired"; readonly message: string } + | { readonly type: "signedOut" } + +export const initialDrive: DriveState = { + connection: { status: "unknown" }, + activity: "idle", + message: null, + sync: null, +} + +function connectionOf(status: DriveStatus): DriveConnection { + return status.state === "connected" + ? { status: "connected", email: status.email } + : { status: "disconnected", onboardingDismissed: status.onboardingDismissed } +} + +export function reduceDrive(state: DriveState, event: DriveEvent): DriveState { + switch (event.type) { + case "statusLoaded": + return { ...state, connection: connectionOf(event.status) } + case "connectStarted": + return state.activity === "idle" && state.connection.status === "disconnected" + ? { ...state, activity: "connecting", message: null } + : state + case "connectSucceeded": + return state.activity === "connecting" + ? { ...state, connection: connectionOf(event.status), activity: "idle", message: null } + : state + case "connectFailed": + return state.activity === "connecting" + ? { ...state, activity: "idle", message: event.message } + : state + case "connectCancelled": + return state.activity === "connecting" ? { ...state, activity: "idle", message: null } : state + case "onboardingDismissed": + return { ...state, connection: connectionOf(event.status), message: null } + case "disconnected": + return { ...state, connection: connectionOf(event.status), message: null, sync: null } + case "failed": + return { ...state, message: event.message } + case "importStarted": + return state.activity === "idle" && state.connection.status === "connected" + ? { ...state, activity: "importing" } + : state + case "imported": + case "importFailed": + return state.activity === "importing" ? { ...state, activity: "idle" } : state + case "syncStarted": + return state.connection.status === "connected" + ? { ...state, sync: { status: "syncing", jobId: event.jobId } } + : state + case "syncFinished": + return state.sync?.jobId === event.jobId + ? { ...state, sync: { status: "done", jobId: event.jobId, result: event.result } } + : state + case "syncFailed": + return state.sync?.jobId === event.jobId + ? { ...state, sync: { status: "failed", jobId: event.jobId, message: event.message } } + : state + case "syncCleared": + return { ...state, sync: null } + case "reconnectRequired": + // The host already forgot the connection; the onboarding card offers it again. + return { + connection: { status: "disconnected", onboardingDismissed: false }, + activity: state.activity === "connecting" ? "connecting" : "idle", + message: event.message, + sync: + state.sync === null + ? null + : { status: "failed", jobId: state.sync.jobId, message: event.message }, + } + case "signedOut": + return initialDrive + } +} diff --git a/src/domain/backend.ts b/src/domain/backend.ts index 3a92512..a7fab40 100644 --- a/src/domain/backend.ts +++ b/src/domain/backend.ts @@ -15,6 +15,7 @@ import type { ChatGptSignInPhase, ChatGptSignOutResult, } from "./chatgpt" +import type { DriveImport, DriveStatus, DriveSyncResult } from "./drive" import type { SpeakerHint } from "./speaker" /** @@ -68,6 +69,17 @@ export interface BackendPort { signInToGateway(): Promise cancelGatewaySignIn(): Promise signOutOfGateway(): Promise + loadDriveStatus(): Promise + /** Resolves once the browser consent finished, failed, was cancelled, or timed out. */ + connectDrive(): Promise + cancelDriveOperation(): Promise + disconnectDrive(): Promise + dismissDriveOnboarding(): Promise + /** Opens the desktop Picker and downloads the pick as job `jobId`; `cancel(jobId)` stops it. */ + importFromDrive(jobId: string): Promise + /** `jobId` is the transcription or transcript-import job the results belong to. */ + syncMeetingToDrive(jobId: string, overwrite: boolean): Promise + openDriveLink(url: string): Promise } /** Whether the app may be used. Only the state and the e-mail cross the IPC border. */ @@ -90,6 +102,8 @@ export interface TranscriptionRequest { readonly inputPath: string readonly outputRoot: string readonly speakerHint: SpeakerHint + /** The Drive file `inputPath` was imported from; absent for local files. */ + readonly driveSourceId?: string } export interface TranscriptImportRequest { diff --git a/src/domain/drive.ts b/src/domain/drive.ts new file mode 100644 index 0000000..cc373c6 --- /dev/null +++ b/src/domain/drive.ts @@ -0,0 +1,79 @@ +import type { ArtifactKind } from "./backend" + +/** What the host tells the window about Drive. No token ever crosses the IPC border. */ +export type DriveStatus = + | { readonly state: "disconnected"; readonly onboardingDismissed: boolean } + | { readonly state: "connected"; readonly email: string } + +/** A recording the Picker chose, downloaded and ready to transcribe from `inputPath`. */ +export interface DriveImport { + readonly jobId: string + readonly inputPath: string + readonly driveFileId: string + readonly name: string +} + +export type DriveSyncState = "uploaded" | "updated" | "unchanged" | "conflict" | "failed" + +export type DriveSyncFile = + | { + readonly kind: ArtifactKind + readonly state: "failed" + readonly message: string + readonly webViewLink: string | null + } + | { + readonly kind: ArtifactKind + readonly state: Exclude + readonly webViewLink: string | null + } + +export interface DriveSyncResult { + readonly folderLink: string | null + readonly files: readonly DriveSyncFile[] +} + +export type DriveTone = "ready" | "pending" | "error" + +const KIND_LABELS: Readonly> = { + srt: "자막", + speaker_text: "화자별 텍스트", + checkpoint: "정렬 체크포인트", + minutes: "회의록", +} + +const STATE_LABELS: Readonly, string>> = { + uploaded: "올림", + updated: "갱신함", + unchanged: "변경 없음", + conflict: "Drive에서 바뀌어 건너뜀", +} + +const DRIVE_CONFLICT_MESSAGE = "Drive의 파일이 다른 곳에서 바뀌어 덮어쓰지 않았습니다." + +/** One line per file: what it is and how it fared. */ +export function syncFileLine(file: DriveSyncFile): string { + const label = KIND_LABELS[file.kind] + return file.state === "failed" + ? `${label} · 올리지 못함: ${file.message}` + : `${label} · ${STATE_LABELS[file.state]}` +} + +/** The headline of a sync: failures first, then conflicts, then what changed. */ +export function syncSummary(result: DriveSyncResult): { + readonly text: string + readonly tone: DriveTone + readonly conflict: boolean +} { + const conflict = result.files.some((file) => file.state === "conflict") + if (result.files.some((file) => file.state === "failed")) { + return { text: "일부 결과를 Drive에 올리지 못했습니다.", tone: "error", conflict } + } + if (conflict) return { text: DRIVE_CONFLICT_MESSAGE, tone: "pending", conflict } + const changed = result.files.some((file) => file.state === "uploaded" || file.state === "updated") + return { + text: changed ? "Drive의 Galpi 폴더에 올렸습니다." : "Drive에 이미 최신 결과가 있습니다.", + tone: "ready", + conflict, + } +} diff --git a/src/icons.css b/src/icons.css index 4dff2e3..c65c941 100644 --- a/src/icons.css +++ b/src/icons.css @@ -2,7 +2,7 @@ * * The upstream stylesheet declares more than 1,500 glyph classes and pulls in * four font formats. Only WOFF2 is needed for the WebView this app ships in, - * and only the 27 icons below are actually used, so the rest is weight the + * and only the 29 icons below are actually used, so the rest is weight the * bundle would carry to no purpose. Adding an icon means adding its class here * with the codepoint from `@phosphor-icons/web/src/regular/style.css`. */ @@ -40,6 +40,12 @@ .ph-check::before { content: "\e182"; } +.ph-cloud::before { + content: "\e1aa"; +} +.ph-cloud-arrow-down::before { + content: "\e1ac"; +} .ph-cpu::before { content: "\e610"; } diff --git a/src/styles.css b/src/styles.css index 333dc2e..dda3cae 100644 --- a/src/styles.css +++ b/src/styles.css @@ -308,6 +308,19 @@ summary:focus-visible { color: var(--seed-color-fg-warning-contrast); } +/* A long Drive address must not push the settings button off a narrow top bar. */ +.drive-chip { + min-width: 0; + max-width: 220px; + flex-shrink: 1; +} + +.drive-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .sign-out-button { flex: 0 0 auto; word-break: keep-all; @@ -708,7 +721,8 @@ summary:focus-visible { #settings-assistant-help, #settings-background-help, #chatgpt-plan-line, -#chatgpt-data-notice { +#chatgpt-data-notice, +#drive-settings-help { margin: var(--seed-dimension-x2) 0 0; color: var(--seed-color-fg-neutral-muted); font-size: var(--seed-font-size-t1); @@ -1172,6 +1186,78 @@ button:disabled:not(.seed-action-button) { margin-top: var(--seed-dimension-x3); } +/* Drive status lines pair their color with text, like every other status. */ +.drive-status { + margin: var(--seed-dimension-x3) 0 0; + color: var(--seed-color-fg-warning-contrast); + font-size: var(--seed-font-size-t2); + font-weight: var(--seed-font-weight-bold); + overflow-wrap: break-word; + word-break: keep-all; +} + +/* An empty live region stays rendered so its first message is announced. */ +.drive-status:empty { + margin: 0; +} + +.drive-status[data-state="ready"] { + color: var(--seed-color-fg-positive-contrast); +} + +.drive-status[data-state="error"] { + color: var(--seed-color-fg-critical-contrast); +} + +.drive-import { + margin-top: var(--seed-dimension-x2_5); +} + +.drive-sync { + margin-top: var(--seed-dimension-x4); + padding: var(--seed-dimension-x3) var(--seed-dimension-x3_5); + background: var(--seed-color-bg-neutral-weak); + border: 1px solid var(--seed-color-stroke-neutral-muted); + border-radius: var(--seed-radius-r2_5); +} + +.drive-sync .drive-status { + margin-top: 0; +} + +.drive-sync-files { + display: grid; + gap: var(--seed-dimension-x1); + margin: var(--seed-dimension-x2) 0 0; + padding: 0; + color: var(--seed-color-fg-neutral-muted); + font-size: var(--seed-font-size-t1); + list-style: none; +} + +.drive-sync-files:empty { + display: none; +} + +.drive-sync-files li[data-state="conflict"] { + color: var(--seed-color-fg-warning-contrast); +} + +.drive-sync-files li[data-state="failed"] { + color: var(--seed-color-fg-critical-contrast); +} + +.drive-sync-actions { + display: flex; + flex-wrap: wrap; + gap: var(--seed-dimension-x3); + margin-top: var(--seed-dimension-x3); +} + +.drive-sync-actions:not(:has(> :not([hidden]))) { + display: none; +} + #chatgpt-welcome { margin-top: var(--seed-dimension-x3); } diff --git a/src/ui/access-controller.ts b/src/ui/access-controller.ts index a16ea05..c79f3c4 100644 --- a/src/ui/access-controller.ts +++ b/src/ui/access-controller.ts @@ -73,6 +73,19 @@ export class AccessController { } } + /** + * The host already closed the session (a feature command answered + * `AUTH_REQUIRED`), so the window follows without asking it again. + */ + expire(): void { + if (this.state.status !== "signedIn") return + this.dispatch({ + type: "sessionExpired", + message: "로그인이 만료되었습니다. 다시 로그인해 주세요.", + }) + this.onClosed() + } + async signOut(): Promise { if (this.state.status !== "signedIn") return this.dispatch({ type: "signOutStarted" }) diff --git a/src/ui/access.dom.test.ts b/src/ui/access.dom.test.ts index a7afbf9..8da2554 100644 --- a/src/ui/access.dom.test.ts +++ b/src/ui/access.dom.test.ts @@ -85,6 +85,14 @@ function backendWith(overrides: Partial, calls: string[]): BackendP signInToGateway: record("signInToGateway", signedIn), cancelGatewaySignIn: record("cancelGatewaySignIn", undefined), signOutOfGateway: record("signOutOfGateway", undefined), + loadDriveStatus: unused, + connectDrive: unused, + cancelDriveOperation: unused, + disconnectDrive: unused, + dismissDriveOnboarding: unused, + importFromDrive: unused, + syncMeetingToDrive: unused, + openDriveLink: unused, ...overrides, } } diff --git a/src/ui/app-template.ts b/src/ui/app-template.ts index c65bff8..68531d1 100644 --- a/src/ui/app-template.ts +++ b/src/ui/app-template.ts @@ -43,6 +43,7 @@ export const appTemplate = `
LOCAL AUDIO WORKSPACE

새 회의 전사

로컬 환경을 확인하는 중입니다.

+
확인 중확인 중
@@ -89,6 +90,19 @@ export const appTemplate = `
+ +
01 / 녹음·파일

새 회의 전사

@@ -116,6 +130,11 @@ export const appTemplate = ` 오디오 파일 선택m4a, mp3, wav, mp4, mov, flac +
출력 폴더
출력 폴더를 선택하세요.
@@ -172,6 +191,14 @@ export const appTemplate = `
화자별 텍스트
정렬 체크포인트
+
@@ -357,6 +384,19 @@ export const appTemplate = `

참석자·제품명·약어·도메인 용어를 적어 두면 잘못 들린 단어와 화자를 보정합니다. 이 컴퓨터에만 저장되고 회의록을 만들 때 함께 전송됩니다.

+
+
+
Google Drive확인 중
+

Drive에서 녹음을 가져오고 전사 결과를 내 Drive의 Galpi 폴더에 자동으로 올립니다. Galpi는 직접 만든 파일과 직접 고른 파일에만 접근합니다(drive.file).

+
+

Drive 연결 상태를 확인하는 중입니다.

+
+ + + +
+

연결을 해제해도 Drive에 올린 파일은 지워지지 않습니다.

+
diff --git a/src/ui/app-view.ts b/src/ui/app-view.ts index beab1c7..950af58 100644 --- a/src/ui/app-view.ts +++ b/src/ui/app-view.ts @@ -15,11 +15,12 @@ import { ChatGptSettingsView } from "./chatgpt-settings" import { GlossarySettingsView } from "./glossary-settings" import { ParticipantPickerView } from "./participant-picker" import { ParticipantSettingsView } from "./participant-settings" +import { DriveView } from "./drive-view" import { setActionButtonVariant } from "./seed" import { bindTokenGuide } from "./token-guide" import { TokenSettingsView } from "./token-settings" -export type BusyKind = "setup" | "transcription" | "refinement" | null +export type BusyKind = "setup" | "transcription" | "refinement" | "driveImport" | null export class AppView { readonly root: HTMLElement @@ -30,10 +31,12 @@ export class AppView { readonly participantSettings: ParticipantSettingsView readonly glossarySettings: GlossarySettingsView readonly attendees: ParticipantPickerView + readonly drive: DriveView private environmentChecked = false private engineReady = false private jobBusy = false private jobKind: BusyKind = null + private driveConnected = false /// Selector lookups for the static template, resolved once each. private readonly elements = new Map() /// What the log and phase slots were last rendered from. A worker under load @@ -67,6 +70,7 @@ export class AppView { }) this.glossarySettings = new GlossarySettingsView(root, () => this.settingsChangeHandler?.()) this.attendees = new ParticipantPickerView(root, (count) => this.applyAttendeeCount(count)) + this.drive = new DriveView(root) bindTokenGuide(this.root) } @@ -231,19 +235,22 @@ export class AppView { this.jobBusy = busy if (kind !== null) { this.jobKind = kind - if (kind === "transcription") { + if (usesJobPanel(kind)) { this.transcriptionSummary = false this.element("#job-panel").dataset["status"] = "running" } this.element("#setup-progress-panel").hidden = kind !== "setup" // Each stage renders its own progress card in place: refinement inside - // the augment panel, transcription inside the transcription panel. - this.element("#job-panel").hidden = kind !== "transcription" && !this.transcriptionSummary + // the augment panel, transcription and Drive import inside the + // transcription panel. + this.element("#job-panel").hidden = !usesJobPanel(kind) && !this.transcriptionSummary this.element("#job-phase-list").hidden = kind !== "transcription" + // A Drive import reports no progress, so it shows no percentage either. + this.element("#job-progress").hidden = kind === "driveImport" + this.element("#job-percent").hidden = kind === "driveImport" this.element("#augment-progress").hidden = kind !== "refinement" } - this.element("#cancel-button").hidden = - !busy || this.jobKind !== "transcription" + this.element("#cancel-button").hidden = !busy || !usesJobPanel(this.jobKind) this.element("#setup-cancel-button").hidden = !busy || this.jobKind !== "setup" this.element("#augment-cancel-button").hidden = @@ -287,10 +294,10 @@ export class AppView { this.element("#setup-progress-panel").hidden = state.status === "idle" || this.jobKind !== "setup" this.element("#job-panel").hidden = - (state.status === "idle" || this.jobKind !== "transcription") && !this.transcriptionSummary + (state.status === "idle" || !usesJobPanel(this.jobKind)) && !this.transcriptionSummary this.element("#augment-progress").hidden = state.status === "idle" || this.jobKind !== "refinement" - if (!this.transcriptionSummary && this.jobKind === "transcription") { + if (!this.transcriptionSummary && usesJobPanel(this.jobKind)) { this.element("#job-panel").dataset["status"] = state.status } this.jobPhase = state.phase @@ -363,6 +370,24 @@ export class AppView { this.startMeeting(true) } + /** + * A recording downloaded from Drive is chosen audio, not a result: the + * progress card folds away and the picker names the Drive file. + */ + renderDriveImported(name: string): void { + this.transcriptionSummary = false + this.element("#job-panel").hidden = true + this.path("#audio-path", name) + this.meetingName = meetingName(name) + this.refreshStages() + } + + /** The import button exists only while Drive is connected. */ + setDriveConnected(connected: boolean): void { + this.driveConnected = connected + this.refreshActions() + } + renderMinutes(minutes: string): void { // The finished minutes row is the completion state; the progress block // hands over to it instead of lingering at 100%. @@ -462,6 +487,8 @@ export class AppView { this.element("#job-title").textContent = transcriptionTitles[status] } else if (this.jobKind === "refinement") { this.element("#augment-job-title").textContent = refinementTitles[status] + } else if (this.jobKind === "driveImport") { + this.element("#job-title").textContent = driveImportTitles[status] } } @@ -517,6 +544,7 @@ export class AppView { return [title, phase === undefined ? "전사 중" : `전사 중 · ${phase} 단계`] } if (this.jobBusy && this.jobKind === "refinement") return [title, "회의록 작성 중"] + if (this.jobBusy && this.jobKind === "driveImport") return [title, "Drive에서 녹음을 가져오는 중"] if (this.hasResult && !this.audioSinceResult) { if (this.minutesReady) return [title, "회의록 완료"] return [title, this.importedResult ? "전사문 가져옴 · 회의록 작성 대기" : "전사 완료 · 회의록 작성 대기"] @@ -563,6 +591,10 @@ export class AppView { this.jobBusy || this.recordingActive this.element("#output-button").disabled = this.jobBusy || this.recordingActive + // Same rule as the other ways to start: never over a running job or recording. + this.element("#drive-import-button").hidden = !this.driveConnected + this.element("#drive-import-button").disabled = + !this.driveConnected || this.jobBusy || this.recordingActive this.element("#refine-button").disabled = !this.hasResult || !this.assistantKeyReady || this.jobBusy || this.recordingActive this.access.setSignOutBlocked(this.jobBusy || this.recordingActive) @@ -608,6 +640,19 @@ const refinementTitles: JobTitles = { cancelled: "회의록 작성을 취소했습니다", } +const driveImportTitles: JobTitles = { + idle: "Drive에서 녹음을 가져오고 있습니다", + running: "Drive에서 녹음을 가져오고 있습니다", + completed: "Drive에서 녹음을 가져왔습니다", + failed: "Drive에서 녹음을 가져오지 못했습니다", + cancelled: "Drive 가져오기를 취소했습니다", +} + +/** Transcription and Drive import both run in the transcription panel's progress card. */ +function usesJobPanel(kind: BusyKind): boolean { + return kind === "transcription" || kind === "driveImport" +} + function setPrimary(button: HTMLElement, primary: boolean): void { button.classList.toggle("primary-button", primary) button.classList.toggle("secondary-button", !primary) @@ -624,6 +669,7 @@ function meetingName(path: string): string { function busyLabel(kind: BusyKind): string { if (kind === "setup") return "로컬 환경 준비 중" if (kind === "transcription") return "회의 전사 중" + if (kind === "driveImport") return "Drive 가져오기" return kind === "refinement" ? "회의록 만드는 중" : "" } diff --git a/src/ui/chatgpt-settings.dom.test.ts b/src/ui/chatgpt-settings.dom.test.ts index aa2f431..9f4cc69 100644 --- a/src/ui/chatgpt-settings.dom.test.ts +++ b/src/ui/chatgpt-settings.dom.test.ts @@ -108,6 +108,14 @@ function backendWith(overrides: Partial): BackendPort { signInToGateway: unused, cancelGatewaySignIn: async () => undefined, signOutOfGateway: async () => undefined, + loadDriveStatus: unused, + connectDrive: unused, + cancelDriveOperation: unused, + disconnectDrive: unused, + dismissDriveOnboarding: unused, + importFromDrive: unused, + syncMeetingToDrive: unused, + openDriveLink: unused, ...overrides, } } diff --git a/src/ui/controller.test.ts b/src/ui/controller.test.ts index a0e0e9e..75972b3 100644 --- a/src/ui/controller.test.ts +++ b/src/ui/controller.test.ts @@ -56,6 +56,14 @@ function unavailableBackend(): BackendPort { signInToGateway: unavailable, cancelGatewaySignIn: unavailable, signOutOfGateway: unavailable, + loadDriveStatus: unavailable, + connectDrive: unavailable, + cancelDriveOperation: unavailable, + disconnectDrive: unavailable, + dismissDriveOnboarding: unavailable, + importFromDrive: unavailable, + syncMeetingToDrive: unavailable, + openDriveLink: unavailable, } } diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 486a0a4..66c659c 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -10,26 +10,38 @@ import { import { type ArtifactKind, type BackendPort, + errorCode, errorDetail, errorMessage, } from "../domain/backend" import type { ComputeDevice, EnginePreset, ImportedTranscript, TranscriptionResult } from "../domain/job" import { buildSpeakerHint, type SpeakerHint } from "../domain/speaker" import { AccessController } from "./access-controller" -import type { AppView } from "./app-view" +import type { AppView, BusyKind } from "./app-view" import { ChatGptController } from "./chatgpt-controller" +import { DriveController } from "./drive-controller" import { RecordingController } from "./recording-controller" +/** + * The audio a transcription starts from. Replaced as a whole whenever the + * audio changes, so a Drive id never outlives the file it came with. + */ +interface AudioSelection { + readonly path: string + readonly driveSourceId: string | null +} + export class AppController { private readonly backend: BackendPort private readonly view: AppView - private audioPath: string | null = null + private audio: AudioSelection | null = null private outputRoot: string | null = null private job: JobViewState = initialJobState private lastResult: TranscriptionResult | ImportedTranscript | null = null private readonly recording: RecordingController private readonly chatgpt: ChatGptController private readonly access: AccessController + private readonly drive: DriveController private unlisten: (() => void) | null = null private unlistenRecording: (() => void) | null = null private settingsSavePending = false @@ -39,7 +51,7 @@ export class AppController { this.backend = backend this.view = view this.recording = new RecordingController(backend, view, (path) => { - this.audioPath = path + this.audio = { path, driveSourceId: null } this.view.setAudio(path) }) this.chatgpt = new ChatGptController(backend, view, () => this.requestSettingsSave()) @@ -47,8 +59,34 @@ export class AppController { backend, view.access, () => this.loadWorkspace(), - () => this.view.tokenSettings.close(), + () => { + this.view.tokenSettings.close() + this.drive.reset() + }, ) + this.drive = new DriveController(backend, view, { + onImportStarted: (jobId) => + this.begin("driveImport", "Drive에서 녹음을 고르고 내려받는 중입니다.", jobId), + onImported: (imported) => { + this.audio = { path: imported.inputPath, driveSourceId: imported.driveFileId } + // Not a transcription result: the job simply ends and the audio is chosen. + this.job = initialJobState + this.view.setAudio(imported.inputPath) + this.view.renderDriveImported(imported.name) + }, + onImportFailed: (error) => { + // Declining consent or closing the Picker is a user decision, not a failure. + if (errorCode(error) === "CANCELLED" && this.job.status !== "cancelled") { + this.job = cancelJob(this.job, errorMessage(error)) + this.view.renderJob(this.job) + return + } + this.handleFailure(error) + }, + onImportFinished: () => this.view.setBusy(null), + onSessionExpired: () => this.access.expire(), + onError: (message) => this.view.showError(message), + }) } async start(): Promise { @@ -95,6 +133,8 @@ export class AppController { } catch (error) { this.view.showError(errorMessage(error)) } + // Separate from the rest: Drive is optional and handles its own failure. + await this.drive.load() } stop(): void { @@ -146,6 +186,13 @@ export class AppController { this.view.on("open-txt", () => void this.openArtifact("speaker_text")) this.view.on("open-checkpoint", () => void this.openArtifact("checkpoint")) this.view.on("reveal-output", () => void this.revealOutput()) + this.view.on("connect-drive", () => void this.drive.connect()) + this.view.on("cancel-drive-operation", () => void this.drive.cancel()) + this.view.on("disconnect-drive", () => void this.drive.disconnect()) + this.view.on("dismiss-drive-onboarding", () => void this.drive.dismiss()) + this.view.on("import-from-drive", () => void this.drive.importFromDrive()) + this.view.on("overwrite-drive", () => void this.drive.overwrite()) + this.view.on("open-drive-folder", () => void this.drive.openFolder()) this.view.onSpeakerMode((mode) => this.view.setSpeakerMode(mode)) } @@ -322,7 +369,7 @@ export class AppController { try { const selected = await this.backend.chooseAudio() if (selected !== null) { - this.audioPath = selected + this.audio = { path: selected, driveSourceId: null } this.view.setAudio(selected) this.view.clearError() } @@ -353,6 +400,8 @@ export class AppController { this.lastResult = imported this.view.setTranscript(selected) this.view.renderImportedTranscript(imported) + this.drive.clearSync() + this.drive.syncAfter(imported.jobId) } catch (error) { this.view.showError(errorMessage(error)) } @@ -371,7 +420,8 @@ export class AppController { } private async transcribe(): Promise { - if (this.audioPath === null) { + const audio = this.audio + if (audio === null) { this.view.showError("먼저 전사할 오디오 파일을 선택해 주세요.") return } @@ -393,14 +443,16 @@ export class AppController { try { const result = await this.backend.transcribe({ jobId, - inputPath: this.audioPath, + inputPath: audio.path, outputRoot: this.outputRoot, speakerHint, + ...(audio.driveSourceId === null ? {} : { driveSourceId: audio.driveSourceId }), }) this.lastResult = result this.job = completeJob(this.job, result) this.view.renderJob(this.job) this.view.renderResult(result) + this.drive.syncAfter(result.jobId) } catch (error) { this.handleFailure(error) } finally { @@ -446,6 +498,8 @@ export class AppController { } this.view.renderJob(this.job) this.view.renderMinutes(refined.minutes) + // The minutes belong to the meeting the refinement targeted. + this.drive.syncAfter(result.jobId) } catch (error) { this.handleFailure(error) } finally { @@ -474,15 +528,19 @@ export class AppController { } private begin( - kind: "setup" | "transcription" | "refinement", + kind: Exclude, message: string, jobId: string | null = null, ): void { this.view.clearError() this.chatgpt.clearLimitHint() + // A new recording or transcription is a new meeting; the old sync row goes. + if (kind === "transcription" || kind === "driveImport") this.drive.clearSync() this.job = beginJob(message, jobId) - this.view.renderJob(this.job) + // The kind first: the card's title and visibility follow the job that is + // starting, not the one that ran before it. this.view.setBusy(kind) + this.view.renderJob(this.job) } private handleFailure(error: unknown): void { diff --git a/src/ui/drive-controller.ts b/src/ui/drive-controller.ts new file mode 100644 index 0000000..61f65b3 --- /dev/null +++ b/src/ui/drive-controller.ts @@ -0,0 +1,174 @@ +import { + type DriveEvent, + type DriveState, + initialDrive, + reduceDrive, +} from "../application/drive-machine" +import { type BackendPort, errorCode, errorMessage } from "../domain/backend" +import type { DriveImport } from "../domain/drive" +import type { AppView } from "./app-view" + +/** + * What the app does around a Drive import, which runs as a job: the job + * panel, busy state, and audio selection belong to `AppController`. + */ +export interface DriveHooks { + readonly onImportStarted: (jobId: string) => void + readonly onImported: (imported: DriveImport) => void + readonly onImportFailed: (error: unknown) => void + readonly onImportFinished: () => void + /** A Drive command answered `AUTH_REQUIRED`: the host closed the session. */ + readonly onSessionExpired: () => void + readonly onError: (message: string) => void +} + +/** + * Drive connection, Picker import, and result sync as the window sees them. + * + * The host owns the tokens and the connection; this controller mirrors the + * status and never retries a failed command. + */ +export class DriveController { + private state: DriveState = initialDrive + + constructor( + private readonly backend: BackendPort, + private readonly view: AppView, + private readonly hooks: DriveHooks, + ) {} + + async load(): Promise { + try { + this.dispatch({ type: "statusLoaded", status: await this.backend.loadDriveStatus() }) + } catch { + // Drive is optional: an unreadable status leaves every Drive control hidden. + } + } + + /** Forget everything on sign-out; the next sign-in loads the status again. */ + reset(): void { + this.dispatch({ type: "signedOut" }) + } + + async connect(): Promise { + if (this.dispatch({ type: "connectStarted" }).activity !== "connecting") return + try { + this.dispatch({ type: "connectSucceeded", status: await this.backend.connectDrive() }) + } catch (error) { + if (errorCode(error) === "CANCELLED") { + this.dispatch({ type: "connectCancelled" }) + return + } + this.dispatch({ type: "connectFailed", message: errorMessage(error) }) + if (errorCode(error) === "AUTH_REQUIRED") this.hooks.onSessionExpired() + } + } + + async cancel(): Promise { + if (this.state.activity !== "connecting") return + try { + await this.backend.cancelDriveOperation() + } catch { + // The consent keeps waiting; its own result still settles the card. + } + } + + async disconnect(): Promise { + if (this.state.connection.status !== "connected") return + try { + this.dispatch({ type: "disconnected", status: await this.backend.disconnectDrive() }) + } catch (error) { + this.dispatch({ type: "failed", message: errorMessage(error) }) + } + } + + async dismiss(): Promise { + try { + this.dispatch({ + type: "onboardingDismissed", + status: await this.backend.dismissDriveOnboarding(), + }) + } catch (error) { + this.dispatch({ type: "failed", message: errorMessage(error) }) + } + } + + async importFromDrive(): Promise { + if (this.dispatch({ type: "importStarted" }).activity !== "importing") return + const jobId = crypto.randomUUID() + this.hooks.onImportStarted(jobId) + try { + const imported = await this.backend.importFromDrive(jobId) + this.dispatch({ type: "imported" }) + this.hooks.onImported(imported) + } catch (error) { + this.dispatch({ type: "importFailed" }) + this.hooks.onImportFailed(error) + this.noteLostConnection(error) + } finally { + this.hooks.onImportFinished() + } + } + + /** Upload a finished meeting's results; does nothing unless Drive is connected. */ + syncAfter(meetingJobId: string): void { + if (this.state.connection.status !== "connected") return + void this.sync(meetingJobId, false) + } + + /** Replace the Drive copies that changed elsewhere, after the user asked to. */ + async overwrite(): Promise { + const sync = this.state.sync + if (sync?.status !== "done") return + await this.sync(sync.jobId, true) + } + + async openFolder(): Promise { + const sync = this.state.sync + if (sync?.status !== "done" || sync.result.folderLink === null) return + try { + await this.backend.openDriveLink(sync.result.folderLink) + } catch (error) { + this.hooks.onError(errorMessage(error)) + } + } + + /** A new meeting starts: the previous meeting's sync no longer describes it. */ + clearSync(): void { + this.dispatch({ type: "syncCleared" }) + } + + private async sync(jobId: string, overwrite: boolean): Promise { + this.dispatch({ type: "syncStarted", jobId }) + try { + const result = await this.backend.syncMeetingToDrive(jobId, overwrite) + this.dispatch({ type: "syncFinished", jobId, result }) + } catch (error) { + this.dispatch({ type: "syncFailed", jobId, message: errorMessage(error) }) + this.noteLostConnection(error) + } + } + + /** + * Follow the host when a failure means the session or the connection is gone. + * A user who just disconnected already sees the disconnected state. + */ + private noteLostConnection(error: unknown): void { + const code = errorCode(error) + if (code === "AUTH_REQUIRED") { + this.hooks.onSessionExpired() + } else if ( + (code === "DRIVE_RECONNECT_REQUIRED" || code === "DRIVE_NOT_CONNECTED") && + this.state.connection.status === "connected" + ) { + this.dispatch({ type: "reconnectRequired", message: errorMessage(error) }) + } + } + + private dispatch(event: DriveEvent): DriveState { + this.state = reduceDrive(this.state, event) + this.view.setDriveConnected(this.state.connection.status === "connected") + this.view.drive.render(this.state) + return this.state + } +} diff --git a/src/ui/drive-view.ts b/src/ui/drive-view.ts new file mode 100644 index 0000000..288b03b --- /dev/null +++ b/src/ui/drive-view.ts @@ -0,0 +1,147 @@ +import type { DriveState } from "../application/drive-machine" +import { type DriveTone, syncFileLine, syncSummary } from "../domain/drive" +import { required } from "./dom" + +const CONNECTING = + "브라우저에서 Google Drive 권한을 허용해 주세요. 창이 보이지 않으면 취소 후 다시 시도하세요." + +/** The settings button is never hidden or disabled: the last resort outside the sheet. */ +const SETTINGS = '[data-action="open-settings"]' + +/** + * When a Drive control hides itself (connect turns into cancel, the card goes + * away), focus moves to the first visible, enabled control that took its place. + * Start actions are disabled while a job or a recording runs, hence the fallback. + */ +const FOCUS_HANDOFF: Readonly> = { + "drive-connect-button": ["#drive-onboarding-cancel-button", "#drive-import-button", SETTINGS], + "drive-onboarding-cancel-button": ["#drive-connect-button", "#drive-import-button", SETTINGS], + "drive-dismiss-button": ["#audio-selection", SETTINGS], + "drive-settings-connect-button": ["#drive-settings-cancel-button", "#drive-disconnect-button"], + "drive-settings-cancel-button": ["#drive-settings-connect-button", "#drive-disconnect-button"], + "drive-disconnect-button": ["#drive-settings-connect-button"], +} + +/** + * Every Drive surface: the onboarding card, the top-bar chip, the settings + * section, and the sync row under the results. Status always pairs its color + * with text. The import button lives with the other start actions in + * `AppView`, which knows when a job or a recording is running. + */ +export class DriveView { + private readonly root: HTMLElement + + constructor(root: HTMLElement) { + this.root = root + } + + /** Call after the import button's visibility is current; focus may move to it. */ + render(state: DriveState): void { + const focused = this.root.ownerDocument.activeElement + const { connection, activity } = state + const connecting = activity === "connecting" + const connected = connection.status === "connected" + const disconnected = connection.status === "disconnected" + + this.element("#drive-onboarding").hidden = !(disconnected && !connection.onboardingDismissed) + const [line, tone] = connectionLine(state) + this.status("#drive-settings-account", line, tone) + // The card already explains itself; its status line only reports progress or trouble. + const eventful = connecting || state.message !== null + this.status("#drive-onboarding-status", eventful ? line : "", tone) + this.element("#drive-connect-button").hidden = connecting + this.element("#drive-dismiss-button").hidden = connecting + this.element("#drive-onboarding-cancel-button").hidden = !connecting + + const chip = this.element("#drive-chip") + chip.hidden = !connected + if (connected) this.element("#drive-email").textContent = connection.email + // The chip truncates a long address; the full one stays a hover away. + chip.title = connected ? connection.email : "" + + this.element("#drive-settings-state").textContent = connected + ? "연결됨" + : disconnected + ? "연결 안 됨" + : "확인 중" + this.element("#drive-settings-connect-button").hidden = !disconnected || connecting + this.element("#drive-settings-cancel-button").hidden = !connecting + this.element("#drive-disconnect-button").hidden = !connected + + this.renderSync(state) + this.handOffFocus(focused) + } + + private handOffFocus(focused: Element | null): void { + if (focused === null || focused.closest("[hidden]") === null) return + const next = (FOCUS_HANDOFF[focused.id] ?? []) + .map((selector) => this.root.querySelector(selector)) + .find( + (candidate) => + candidate !== null && + candidate.closest("[hidden]") === null && + !candidate.matches(":disabled"), + ) + next?.focus() + } + + private renderSync({ sync }: DriveState): void { + this.element("#drive-sync-row").hidden = sync === null + const files = this.element("#drive-sync-files") + const folder = this.element("#drive-open-folder-button") + const overwrite = this.element("#drive-overwrite-button") + if (sync === null) { + // The next meeting's sync starts from an empty row, not the last one's files. + files.replaceChildren() + folder.hidden = true + overwrite.hidden = true + return + } + if (sync.status === "syncing") { + this.status("#drive-sync-status", "Drive에 결과를 올리는 중입니다.", "pending") + overwrite.disabled = true + return + } + if (sync.status === "failed") { + this.status("#drive-sync-status", sync.message, "error") + files.replaceChildren() + folder.hidden = true + overwrite.hidden = true + return + } + const summary = syncSummary(sync.result) + this.status("#drive-sync-status", summary.text, summary.tone) + files.replaceChildren( + ...sync.result.files.map((file) => { + const item = this.root.ownerDocument.createElement("li") + item.textContent = syncFileLine(file) + item.dataset["state"] = file.state + return item + }), + ) + folder.hidden = sync.result.folderLink === null + overwrite.hidden = !summary.conflict + overwrite.disabled = false + } + + private status(selector: string, text: string, tone: DriveTone): void { + const element = this.element(selector) + element.textContent = text + element.dataset["state"] = tone + } + + private element(selector: string): T { + return required(this.root, selector) + } +} + +function connectionLine({ connection, activity, message }: DriveState): readonly [string, DriveTone] { + if (activity === "connecting") return [CONNECTING, "pending"] + if (message !== null) return [message, "error"] + if (connection.status === "connected") { + return [`${connection.email} 계정의 Drive에 연결했습니다.`, "ready"] + } + return connection.status === "disconnected" + ? ["Drive에 연결하지 않았습니다.", "pending"] + : ["Drive 연결 상태를 확인하는 중입니다.", "pending"] +} diff --git a/src/ui/drive.dom.test.ts b/src/ui/drive.dom.test.ts new file mode 100644 index 0000000..f3b14b1 --- /dev/null +++ b/src/ui/drive.dom.test.ts @@ -0,0 +1,683 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { Window } from "happy-dom" + +import type { BackendPort, TranscriptionRequest } from "../domain/backend" +import type { DriveImport, DriveStatus, DriveSyncResult } from "../domain/drive" +import type { TranscriptionResult } from "../domain/job" +import { AppView } from "./app-view" +import { AppController } from "./controller" + +const styles = await Bun.file(new URL("../styles.css", import.meta.url)).text() + +// The recording path drives `window.setInterval` and document listeners, so +// a DOM is installed globally like in the recording controller's own tests. +const previous = { + window: Reflect.get(globalThis, "window") as unknown, + document: Reflect.get(globalThis, "document") as unknown, +} +let globalDom: Window + +beforeAll(() => { + globalDom = new Window() + Object.assign(globalThis, { window: globalDom, document: globalDom.document }) +}) + +afterAll(() => { + Object.assign(globalThis, previous) + void globalDom.happyDOM.close() +}) + +const connected: DriveStatus = { state: "connected", email: "dev@example.com" } +const fresh: DriveStatus = { state: "disconnected", onboardingDismissed: false } +const dismissed: DriveStatus = { state: "disconnected", onboardingDismissed: true } + +const transcribed: TranscriptionResult = { + jobId: "transcription-1", + srt: "/out/회의/회의.srt", + txt: "/out/회의/회의_화자별.txt", + checkpoint: null, + outputDirectory: "/out/회의", + segments: 3, + filtered: 0, +} + +const uploaded: DriveSyncResult = { + folderLink: "https://drive.google.com/drive/folders/m1", + files: [ + { kind: "srt", state: "uploaded", webViewLink: null }, + { kind: "speaker_text", state: "uploaded", webViewLink: null }, + ], +} + +const imported = (jobId: string): DriveImport => ({ + jobId, + inputPath: "/cache/drive-imports/u1/주간 회의.m4a", + driveFileId: "file-1", + name: "주간 회의.m4a", +}) + +function failWith(code: string, message: string): () => Promise { + return () => Promise.reject({ code, message }) +} + +/** A signed-in, engine-ready host; anything a test does not override is unused. */ +function backend(overrides: Partial, calls: string[] = []): BackendPort { + const unused = (name: string) => () => Promise.reject(new Error(`unused: ${name}`)) + const record = + (name: string, value: T) => + async (): Promise => { + calls.push(name) + return value + } + return { + diagnose: async () => ({ + enginePreset: "qwen3" as const, + engineReady: true, + modelsReady: true, + ffmpegReady: true, + qwen3Ready: true, + whisperxReady: false, + dataDirectory: "/tmp/galpi", + defaultOutputDirectory: "/out", + engineVersion: "test", + computeDevice: "cpu" as const, + availablePresets: ["qwen3", "whisperx"] as const, + availableDevices: [] as const, + cudaDriverDetected: false, + }), + prepare: unused("prepare"), + huggingFaceTokenStored: async () => false, + saveHuggingFaceToken: unused("saveHuggingFaceToken"), + loadAssistantSettings: async () => ({ + apiKeyStored: true, + model: null, + baseUrl: null, + reasoningEffort: null, + background: null, + participants: [], + glossary: [], + }), + saveAssistantApiKey: unused("saveAssistantApiKey"), + saveAssistantSettings: async () => undefined, + saveEnginePreset: unused("saveEnginePreset"), + saveComputeDevice: unused("saveComputeDevice"), + refineTranscript: unused("refineTranscript"), + transcribe: unused("transcribe"), + importTranscript: unused("importTranscript"), + cancel: async (jobId) => { + calls.push(`cancel:${jobId}`) + }, + openArtifact: unused("openArtifact"), + revealOutput: unused("revealOutput"), + startRecording: unused("startRecording"), + stopRecording: unused("stopRecording"), + cancelRecording: unused("cancelRecording"), + listenToRecordingFailures: async () => () => undefined, + chooseAudio: async () => "/local/로컬 회의.m4a", + chooseTranscript: unused("chooseTranscript"), + chooseOutputDirectory: unused("chooseOutputDirectory"), + openModelAccessPage: unused("openModelAccessPage"), + listenToJobs: async () => () => undefined, + loadChatGptSettings: async () => ({ + authMode: "apiKey" as const, + model: null, + welcomeAcknowledged: true, + account: { state: "signedOut" as const, email: null }, + }), + saveChatGptPreferences: async () => undefined, + signInWithChatGpt: unused("signInWithChatGpt"), + cancelChatGptSignIn: unused("cancelChatGptSignIn"), + listChatGptModels: async () => [], + signOutOfChatGpt: unused("signOutOfChatGpt"), + openChatGptUsagePage: unused("openChatGptUsagePage"), + listenToChatGptEvents: async () => () => undefined, + loadAppAccess: async () => ({ state: "signedIn", email: "dev@example.com", offline: false }), + signInToGateway: unused("signInToGateway"), + cancelGatewaySignIn: unused("cancelGatewaySignIn"), + signOutOfGateway: unused("signOutOfGateway"), + loadDriveStatus: async () => connected, + connectDrive: unused("connectDrive"), + cancelDriveOperation: record("cancelDriveOperation", undefined), + disconnectDrive: unused("disconnectDrive"), + dismissDriveOnboarding: record("dismissDriveOnboarding", dismissed), + importFromDrive: unused("importFromDrive"), + syncMeetingToDrive: async (jobId, overwrite) => { + calls.push(`sync:${jobId}:${overwrite}`) + return uploaded + }, + openDriveLink: unused("openDriveLink"), + ...overrides, + } +} + +async function mount(port: BackendPort): Promise<{ controller: AppController; root: HTMLElement }> { + const window = new Window() + const sheet = window.document.createElement("style") + sheet.textContent = styles + window.document.head.appendChild(sheet) + const root = window.document.createElement("div") as unknown as HTMLElement + window.document.body.appendChild(root as unknown as never) + const controller = new AppController(port, new AppView(root)) + await controller.start() + await settle() + return { controller, root } +} + +// Microtask drain instead of a wall-clock wait. +async function settle(): Promise { + for (let tick = 0; tick < 40; tick += 1) await Promise.resolve() +} + +function pick(root: HTMLElement, selector: string): T { + const element = root.querySelector(selector) + if (element === null) throw new Error(`missing ${selector}`) + return element +} + +function visible(root: HTMLElement, selector: string): boolean { + return pick(root, selector).closest("[hidden]") === null +} + +async function click(root: HTMLElement, selector: string): Promise { + pick(root, selector).click() + await settle() +} + +/** Text a person can see: inside no hidden ancestor. */ +function visibleText(root: HTMLElement): string { + return [...root.querySelectorAll("*")] + .filter((element) => element.children.length === 0 && element.closest("[hidden]") === null) + .map((element) => element.textContent ?? "") + .join("\n") +} + +describe("Drive onboarding and settings", () => { + test("the card shows until dismissed, and only while disconnected", async () => { + // Given + const calls: string[] = [] + const { controller, root } = await mount( + backend({ loadDriveStatus: async () => fresh }, calls), + ) + expect(visible(root, "#drive-onboarding")).toBe(true) + expect(visible(root, "#drive-import-button")).toBe(false) + + // When + await click(root, "#drive-dismiss-button") + + // Then + expect(calls).toContain("dismissDriveOnboarding") + expect(visible(root, "#drive-onboarding")).toBe(false) + controller.stop() + }) + + test("connecting from the card can be cancelled there, and success hides the card", async () => { + // Given: a consent waiting in the browser + const calls: string[] = [] + const consent = Promise.withResolvers() + const { controller, root } = await mount( + backend({ loadDriveStatus: async () => fresh, connectDrive: () => consent.promise }, calls), + ) + + // When + pick(root, "#drive-connect-button").focus() + await click(root, "#drive-connect-button") + + // Then: the card offers a cancel instead of the connect button, and focus follows it + expect(visible(root, "#drive-onboarding-cancel-button")).toBe(true) + expect(visible(root, "#drive-connect-button")).toBe(false) + expect(root.ownerDocument.activeElement?.id).toBe("drive-onboarding-cancel-button") + expect(pick(root, "#drive-onboarding-status").textContent).toContain("브라우저") + await click(root, "#drive-onboarding-cancel-button") + expect(calls.filter((call) => call === "cancelDriveOperation")).toHaveLength(1) + + // When: the consent completes anyway + consent.resolve(connected) + await settle() + + // Then + expect(visible(root, "#drive-onboarding")).toBe(false) + expect(visible(root, "#drive-chip")).toBe(true) + expect(pick(root, "#drive-email").textContent).toBe("dev@example.com") + expect(visible(root, "#drive-import-button")).toBe(true) + expect(root.ownerDocument.activeElement?.id).toBe("drive-import-button") + controller.stop() + }) + + test("dismissing the card while a transcription runs hands focus to settings", async () => { + // Given: a running transcription disables the start actions + const running = Promise.withResolvers() + const { controller, root } = await mount( + backend({ loadDriveStatus: async () => fresh, transcribe: () => running.promise }), + ) + await click(root, '[data-action="choose-audio"]') + await click(root, "#start-button") + expect(pick(root, "#audio-selection").disabled).toBe(true) + + // When + pick(root, "#drive-dismiss-button").focus() + await click(root, "#drive-dismiss-button") + + // Then + expect(visible(root, "#drive-onboarding")).toBe(false) + expect(root.ownerDocument.activeElement?.matches('[data-action="open-settings"]')).toBe(true) + running.reject({ code: "CANCELLED", message: "사용자가 작업을 취소했습니다." }) + await settle() + controller.stop() + }) + + test("connecting from settings shows the settings cancel button, which cancels once", async () => { + // Given: the card was dismissed, so settings is the way in + const calls: string[] = [] + const consent = Promise.withResolvers() + const { controller, root } = await mount( + backend({ loadDriveStatus: async () => dismissed, connectDrive: () => consent.promise }, calls), + ) + await click(root, '[data-action="open-settings"]') + expect(pick(root, "#drive-settings-state").textContent).toBe("연결 안 됨") + + // When + await click(root, "#drive-settings-connect-button") + await click(root, "#drive-settings-cancel-button") + consent.reject({ code: "CANCELLED", message: "사용자가 작업을 취소했습니다." }) + await settle() + + // Then + expect(calls.filter((call) => call === "cancelDriveOperation")).toHaveLength(1) + expect(visible(root, "#drive-settings-cancel-button")).toBe(false) + expect(visible(root, "#drive-settings-connect-button")).toBe(true) + controller.stop() + }) + + test("a Drive command refused for sign-in returns to the login screen", async () => { + // Given + const { controller, root } = await mount( + backend({ + loadDriveStatus: async () => fresh, + connectDrive: failWith("AUTH_REQUIRED", "Galpi를 사용하려면 Google로 로그인해 주세요."), + }), + ) + + // When + await click(root, "#drive-connect-button") + + // Then + expect(visible(root, "#access-sign-in-button")).toBe(true) + expect(pick(root, ".app-shell").hasAttribute("inert")).toBe(true) + controller.stop() + }) + + test("an unreadable status offers no Drive action anywhere", async () => { + // Given + const { controller, root } = await mount( + backend({ loadDriveStatus: () => Promise.reject(new Error("unreadable")) }), + ) + + // When + await click(root, '[data-action="open-settings"]') + + // Then + for (const selector of [ + "#drive-onboarding", + "#drive-settings-connect-button", + "#drive-disconnect-button", + "#drive-import-button", + ]) { + expect(visible(root, selector)).toBe(false) + } + controller.stop() + }) + + test("disconnecting from settings hides every connected surface", async () => { + // Given + const { controller, root } = await mount( + backend({ disconnectDrive: async () => dismissed }), + ) + await click(root, '[data-action="open-settings"]') + + // When + await click(root, "#drive-disconnect-button") + + // Then + expect(visible(root, "#drive-chip")).toBe(false) + expect(root.querySelector("#drive-import-button")?.hidden).toBe(true) + expect(pick(root, "#drive-settings-state").textContent).toBe("연결 안 됨") + controller.stop() + }) + + test("a sync ended by the user's own disconnect does not bring the card back", async () => { + // Given: the card was dismissed and a sync is still running + const sync = Promise.withResolvers() + const { controller, root } = await mount( + backend({ + transcribe: async () => transcribed, + syncMeetingToDrive: () => sync.promise, + disconnectDrive: async () => dismissed, + }), + ) + await click(root, '[data-action="choose-audio"]') + await click(root, "#start-button") + + // When: the user disconnects, so the host ends the sync as not connected + await click(root, "#drive-disconnect-button") + sync.reject({ code: "DRIVE_NOT_CONNECTED", message: "먼저 Google Drive를 연결해 주세요." }) + await settle() + + // Then + expect(visible(root, "#drive-onboarding")).toBe(false) + expect(pick(root, "#drive-settings-state").textContent).toBe("연결 안 됨") + controller.stop() + }) +}) + +describe("Drive sync after a meeting", () => { + async function transcribedMeeting(overrides: Partial, calls: string[] = []) { + const mounted = await mount( + backend({ transcribe: async () => transcribed, ...overrides }, calls), + ) + await click(mounted.root, '[data-action="choose-audio"]') + await click(mounted.root, "#start-button") + return mounted + } + + test("a finished transcription syncs, and only while Drive is connected", async () => { + // Given / When + const calls: string[] = [] + const { controller, root } = await transcribedMeeting({}, calls) + + // Then + expect(calls).toContain("sync:transcription-1:false") + expect(visible(root, "#drive-sync-row")).toBe(true) + expect(pick(root, "#drive-sync-status").textContent).toBe("Drive의 Galpi 폴더에 올렸습니다.") + expect(pick(root, "#drive-sync-status").dataset["state"]).toBe("ready") + controller.stop() + + // And: nothing is synced without a connection + const offline: string[] = [] + const second = await transcribedMeeting({ loadDriveStatus: async () => dismissed }, offline) + expect(offline.some((call) => call.startsWith("sync:"))).toBe(false) + second.controller.stop() + }) + + test("a conflict offers to overwrite, which syncs again with overwrite", async () => { + // Given + const calls: string[] = [] + let answer: DriveSyncResult = { + folderLink: null, + files: [{ kind: "speaker_text", state: "conflict", webViewLink: null }], + } + const { controller, root } = await transcribedMeeting( + { + syncMeetingToDrive: async (jobId, overwrite) => { + calls.push(`sync:${jobId}:${overwrite}`) + return answer + }, + }, + calls, + ) + expect(pick(root, "#drive-sync-status").textContent).toBe( + "Drive의 파일이 다른 곳에서 바뀌어 덮어쓰지 않았습니다.", + ) + expect(visible(root, "#drive-overwrite-button")).toBe(true) + + // When + answer = { folderLink: null, files: [{ kind: "speaker_text", state: "updated", webViewLink: null }] } + await click(root, "#drive-overwrite-button") + + // Then + expect(calls).toContain("sync:transcription-1:true") + expect(visible(root, "#drive-overwrite-button")).toBe(false) + controller.stop() + }) + + test("a sync that needs reconnecting brings the onboarding card back", async () => { + // Given / When + const { controller, root } = await transcribedMeeting({ + syncMeetingToDrive: failWith( + "DRIVE_RECONNECT_REQUIRED", + "Drive 연결이 만료되었습니다. 다시 연결해 주세요.", + ), + }) + + // Then + expect(visible(root, "#drive-onboarding")).toBe(true) + expect(pick(root, "#drive-sync-status").textContent).toBe( + "Drive 연결이 만료되었습니다. 다시 연결해 주세요.", + ) + expect(visible(root, "#drive-import-button")).toBe(false) + controller.stop() + }) + + test("refinement syncs under the transcription job, and an imported transcript syncs too", async () => { + // Given + const calls: string[] = [] + const { controller, root } = await transcribedMeeting( + { + refineTranscript: async (jobId) => ({ jobId, minutes: "/out/회의/회의_회의록.md" }), + chooseTranscript: async () => "/notes/회의.txt", + importTranscript: async () => ({ + jobId: "import-1", + txt: "/out/회의 2/회의.txt", + outputDirectory: "/out/회의 2", + }), + }, + calls, + ) + + // When + await click(root, "#refine-button") + await click(root, "#transcript-selection") + + // Then + expect(calls.filter((call) => call === "sync:transcription-1:false")).toHaveLength(2) + expect(calls).toContain("sync:import-1:false") + controller.stop() + }) + + test("the next meeting's sync starts without the previous meeting's files or folder link", async () => { + // Given: meeting A synced, and meeting B's sync still running + const second = Promise.withResolvers() + let syncs = 0 + let transcriptions = 0 + const { controller, root } = await transcribedMeeting({ + transcribe: async () => { + transcriptions += 1 + return { ...transcribed, jobId: `transcription-${transcriptions}` } + }, + syncMeetingToDrive: () => { + syncs += 1 + return syncs === 1 ? Promise.resolve(uploaded) : second.promise + }, + }) + expect(pick(root, "#drive-sync-files").children).toHaveLength(2) + + // When + await click(root, "#start-button") + + // Then + expect(pick(root, "#drive-sync-status").textContent).toBe("Drive에 결과를 올리는 중입니다.") + expect(pick(root, "#drive-sync-files").children).toHaveLength(0) + expect(visible(root, "#drive-open-folder-button")).toBe(false) + second.resolve(uploaded) + await settle() + controller.stop() + }) +}) + +describe("Drive import as a job", () => { + test("a pending import shows a running card whose cancel targets the import job", async () => { + // Given: a fresh session and an import still in the Picker + const calls: string[] = [] + const pending = Promise.withResolvers() + let importJob = "" + const { controller, root } = await mount( + backend( + { + importFromDrive: (jobId) => { + importJob = jobId + return pending.promise + }, + }, + calls, + ), + ) + + // When + await click(root, "#drive-import-button") + + // Then + expect(visible(root, "#cancel-button")).toBe(true) + expect(pick(root, "#job-panel").dataset["status"]).toBe("running") + expect(pick(root, "#job-title").textContent).toBe("Drive에서 녹음을 가져오고 있습니다") + expect(pick(root, "#drive-import-button").disabled).toBe(true) + await click(root, "#cancel-button") + expect(calls).toContain(`cancel:${importJob}`) + pending.reject({ code: "CANCELLED", message: "사용자가 작업을 취소했습니다." }) + await settle() + expect(pick(root, "#job-title").textContent).toBe("Drive 가져오기를 취소했습니다") + controller.stop() + }) + + test("a finished import hides the card and names the file, even after a transcription", async () => { + // Given: a session that already finished a transcription + const { controller, root } = await mount( + backend({ + transcribe: async () => transcribed, + importFromDrive: async (jobId) => imported(jobId), + }), + ) + await click(root, '[data-action="choose-audio"]') + await click(root, "#start-button") + expect(visibleText(root)).toContain("전사 완료") + + // When + await click(root, "#drive-import-button") + + // Then + expect(visible(root, "#job-panel")).toBe(false) + expect(visibleText(root)).not.toContain("전사 완료") + expect(pick(root, "#audio-path").textContent).toBe("주간 회의.m4a") + expect(pick(root, "#task-status").textContent).toBe("전사 준비됨") + expect(pick(root, "#drive-import-button").disabled).toBe(false) + controller.stop() + }) + + test("a refused import fails the card with the host's reason and frees the app", async () => { + // Given + const { controller, root } = await mount( + backend({ + importFromDrive: failWith( + "DRIVE_UNSUPPORTED_FILE", + "오디오나 영상 파일만 가져올 수 있습니다.", + ), + }), + ) + + // When + await click(root, "#drive-import-button") + + // Then + expect(pick(root, "#job-panel").dataset["status"]).toBe("failed") + expect(pick(root, "#error-message").textContent).toBe("오디오나 영상 파일만 가져올 수 있습니다.") + expect(visible(root, "#error-message")).toBe(true) + expect(pick(root, "#job-title").textContent).toBe("Drive에서 녹음을 가져오지 못했습니다") + expect(pick(root, "#record-button").disabled).toBe(false) + expect(pick(root, "#drive-import-button").disabled).toBe(false) + controller.stop() + }) + + test("an import declined in the browser cancels the card instead of failing it", async () => { + // Given + const { controller, root } = await mount( + backend({ importFromDrive: failWith("CANCELLED", "사용자가 작업을 취소했습니다.") }), + ) + + // When + await click(root, "#drive-import-button") + + // Then + expect(pick(root, "#job-panel").dataset["status"]).toBe("cancelled") + expect(pick(root, "#job-title").textContent).toBe("Drive 가져오기를 취소했습니다") + expect(visible(root, "#error-message")).toBe(false) + expect(pick(root, "#drive-import-button").disabled).toBe(false) + controller.stop() + }) + + test("the import button waits while a transcription runs, whose cancel stays its own", async () => { + // Given: a transcription still running + const calls: string[] = [] + const running = Promise.withResolvers() + let transcriptionJob = "" + const { controller, root } = await mount( + backend( + { + transcribe: (request) => { + transcriptionJob = request.jobId + return running.promise + }, + }, + calls, + ), + ) + await click(root, '[data-action="choose-audio"]') + + // When + await click(root, "#start-button") + + // Then + expect(pick(root, "#drive-import-button").disabled).toBe(true) + await click(root, "#cancel-button") + expect(calls).toContain(`cancel:${transcriptionJob}`) + running.reject({ code: "CANCELLED", message: "사용자가 작업을 취소했습니다." }) + await settle() + controller.stop() + }) + + test("only audio from Drive carries its Drive id into the transcription request", async () => { + // Given + const requests: TranscriptionRequest[] = [] + const { controller, root } = await mount( + backend({ + importFromDrive: async (jobId) => imported(jobId), + transcribe: async (request) => { + requests.push(request) + return { ...transcribed, jobId: request.jobId } + }, + startRecording: async () => ({ + recordingId: "rec-1", + path: "/out/녹음/녹음.wav.part", + sampleRate: 48_000, + channels: 1, + }), + stopRecording: async () => ({ + recordingId: "rec-1", + path: "/out/녹음/녹음.wav", + sampleRate: 48_000, + channels: 1, + frames: 48_000, + droppedFrames: 0, + durationSeconds: 1, + }), + }), + ) + + // When: Drive audio, then a local file, then Drive again, then a recording + await click(root, "#drive-import-button") + await click(root, "#start-button") + await click(root, '[data-action="choose-audio"]') + await click(root, "#start-button") + await click(root, "#drive-import-button") + await click(root, "#record-button") + await click(root, "#stop-recording-button") + await click(root, "#start-button") + + // Then + expect(requests.map((request) => [request.inputPath, request.driveSourceId])).toEqual([ + ["/cache/drive-imports/u1/주간 회의.m4a", "file-1"], + ["/local/로컬 회의.m4a", undefined], + ["/out/녹음/녹음.wav", undefined], + ]) + // The key itself is absent, so the host sees no claim at all. + expect(requests.slice(1).some((request) => "driveSourceId" in request)).toBe(false) + controller.stop() + }) +}) diff --git a/src/ui/settings-autosave.dom.test.ts b/src/ui/settings-autosave.dom.test.ts index c190cd6..c6e8180 100644 --- a/src/ui/settings-autosave.dom.test.ts +++ b/src/ui/settings-autosave.dom.test.ts @@ -117,6 +117,20 @@ function createBackend( }, cancelGatewaySignIn: async () => undefined, signOutOfGateway: async () => undefined, + loadDriveStatus: async () => ({ state: "disconnected", onboardingDismissed: true }), + connectDrive: async () => { + throw new Error("unused Drive connect") + }, + cancelDriveOperation: async () => undefined, + disconnectDrive: async () => ({ state: "disconnected", onboardingDismissed: true }), + dismissDriveOnboarding: async () => ({ state: "disconnected", onboardingDismissed: true }), + importFromDrive: async () => { + throw new Error("unused Drive import") + }, + syncMeetingToDrive: async () => { + throw new Error("unused Drive sync") + }, + openDriveLink: async () => undefined, } }