From 8918381a0aece2d69712a038dd8fc90a5144a7ab Mon Sep 17 00:00:00 2001 From: m16khb Date: Sun, 11 Oct 2026 04:49:19 +0900 Subject: [PATCH 1/7] feat(drive): import recordings from and sync results to Google Drive in the host Why: galpi#12. Users keep meeting recordings in Google Drive and want the transcripts back there without moving files by hand. The host now connects a Drive account with the drive.file scope, imports one recording picked in the system browser's desktop Picker, and uploads the meeting's results into a Galpi folder per meeting. - domain/drive.rs: MeetingKey (`drive:` or FNV-1a of the canonical source path, so a re-transcription into `{stem} 2` syncs to the same folder), the stateless conflict rule `decide` over appProperties galpiSha256 vs Drive sha256Checksum, file-id and token value objects. - application/drive.rs: DriveAccounts behind three new ports (DriveAuthPort, DriveFilesPort, DriveSessionStore); connect/disconnect under a store lock, token renewal near expiry, invalid_grant is the only failure that forgets the connection, gateway 401 signs the app out. Picker tokens are stored only while the same account is still connected. - AppAccessGate gains bearer()/expire(): the auth-gateway session pays for POST /drive/native/token, which holds the OAuth client secret; the source carries only the desktop client ID. - adapters/outbound/drive: consent over the shared browser_sign_in loopback/PKCE, gateway-brokered token exchange, Drive v3 REST (multipart upload, streamed download into cache/drive-imports, cleaned on failure, cancel and startup). - tauri.rs/composition.rs: 7 commands (35 total); settings and the secret store keep the Drive session (Windows entry com.m16khb.galpi:drive-session); opener allows drive.google.com. Tested: cargo test --manifest-path src-tauri/Cargo.toml --all-targets (295 passed, 1 ignored); cargo clippy --all-targets -- -D warnings; cargo fmt --check; live smoke against real Google, auth-gateway and Drive (connect, Picker import, upload, unchanged, updated, conflict, overwrite, minutes, disconnect) recorded in .issueops/evidence/task-12-drive-smoke.md Not-tested: memory during a 50MB+ download; the full flow inside the app window (no engine/models on the test machine) --- src-tauri/capabilities/default.json | 3 +- src-tauri/src/adapters/inbound/tauri.rs | 54 + .../outbound/browser_sign_in/fake_server.rs | 19 + .../src/adapters/outbound/drive/authorize.rs | 85 ++ src-tauri/src/adapters/outbound/drive/mod.rs | 184 +++ src-tauri/src/adapters/outbound/drive/rest.rs | 749 ++++++++++ .../src/adapters/outbound/drive/tests.rs | 924 ++++++++++++ .../src/adapters/outbound/drive/tokens.rs | 106 ++ .../src/adapters/outbound/gateway/mod.rs | 3 +- src-tauri/src/adapters/outbound/import.rs | 1 + src-tauri/src/adapters/outbound/mod.rs | 1 + src-tauri/src/adapters/outbound/secrets.rs | 3 + src-tauri/src/adapters/outbound/settings.rs | 28 + .../src/adapters/outbound/settings/drive.rs | 278 ++++ .../src/adapters/outbound/transcription.rs | 1 + src-tauri/src/application/drive.rs | 536 +++++++ src-tauri/src/application/gateway.rs | 72 +- src-tauri/src/application/mod.rs | 1 + src-tauri/src/application/ports.rs | 151 +- src-tauri/src/application/tests.rs | 56 +- src-tauri/src/application/tests/chatgpt.rs | 5 + src-tauri/src/application/tests/drive.rs | 1245 +++++++++++++++++ src-tauri/src/application/tests/gateway.rs | 122 +- src-tauri/src/application/use_cases.rs | 132 +- src-tauri/src/composition.rs | 34 +- src-tauri/src/domain/artifact.rs | 10 +- src-tauri/src/domain/drive.rs | 440 ++++++ src-tauri/src/domain/job.rs | 4 + src-tauri/src/domain/mod.rs | 1 + 29 files changed, 5208 insertions(+), 40 deletions(-) create mode 100644 src-tauri/src/adapters/outbound/drive/authorize.rs create mode 100644 src-tauri/src/adapters/outbound/drive/mod.rs create mode 100644 src-tauri/src/adapters/outbound/drive/rest.rs create mode 100644 src-tauri/src/adapters/outbound/drive/tests.rs create mode 100644 src-tauri/src/adapters/outbound/drive/tokens.rs create mode 100644 src-tauri/src/adapters/outbound/settings/drive.rs create mode 100644 src-tauri/src/application/drive.rs create mode 100644 src-tauri/src/application/tests/drive.rs create mode 100644 src-tauri/src/domain/drive.rs 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/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..a54e8c1 --- /dev/null +++ b/src-tauri/src/adapters/outbound/drive/rest.rs @@ -0,0 +1,749 @@ +//! 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, meeting_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. +fn local_name(name: &str) -> String { + let path = Path::new(name); + let stem = meeting_stem(path); + match path + .extension() + .and_then(|extension| extension.to_str()) + .filter(|extension| { + !extension.is_empty() && extension.bytes().all(|byte| byte.is_ascii_alphanumeric()) + }) { + Some(extension) => format!("{stem}.{extension}"), + None => 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"); + } + + #[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 "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; From f5f719ee39607fe623e2701fd5e1b99d606a8e80 Mon Sep 17 00:00:00 2001 From: m16khb Date: Sun, 11 Oct 2026 04:49:33 +0900 Subject: [PATCH 2/7] feat(drive): add Drive onboarding, import, and sync to the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: galpi#12. The host's Drive commands need a surface: a way to connect, a way to bring a recording in from Drive, and a visible result of each meeting's upload, all following DESIGN.md (SEED tokens only, status as color plus text, no progress percentages that the download cannot back). - drive-machine.ts: pure reducer for connection, consent wait, and sync state; drive-controller.ts drives it and follows AUTH_REQUIRED back to the login screen; a Drive failure after the user's own disconnect does not bring the onboarding card back. - Onboarding card (Drive 연결 / 나중에 / cancel), top-bar Drive chip (truncates a long address, full one in its title), settings section, and the sync row with per-file lines, Drive 폴더 열기, and an explicit Drive 버전 덮어쓰기 on a conflict. - Drive에서 가져오기 runs as a job on the progress card with 작업 취소; a consent declined or Picker closed in the browser shows as cancelled. begin() sets the job kind before rendering so the card title follows the new job. Audio from Drive carries driveSourceId into the transcription request; recording or picking a local file replaces it. - Focus moves to the control that replaces a hidden Drive button (settings when start actions are disabled); empty status lines stay live regions. - tauri-backend.ts: strict Zod schemas so no Drive token reaches the window. Tested: bun run check; bun test (233 pass); Chromium via Vite with a fake Tauri IPC (onboarding, connect wait, chip, import card, sync conflict, settings; 1024px long-address layout; keyboard focus handoff); combined web QA (aside-web-qa) with no functional defect Not-tested: the window inside the real app (no engine/models on the test machine); prefers-reduced-motion toggled at runtime (no new animation) --- DESIGN.md | 9 + src/adapters/tauri-backend.test.ts | 57 +++ src/adapters/tauri-backend.ts | 75 +++ src/application/access-machine.test.ts | 15 + src/application/access-machine.ts | 4 + src/application/drive-machine.test.ts | 137 +++++ src/application/drive-machine.ts | 119 +++++ src/domain/backend.ts | 14 + src/domain/drive.ts | 79 +++ src/icons.css | 8 +- src/styles.css | 88 +++- src/ui/access-controller.ts | 13 + src/ui/access.dom.test.ts | 8 + src/ui/app-template.ts | 40 ++ src/ui/app-view.ts | 62 ++- src/ui/chatgpt-settings.dom.test.ts | 8 + src/ui/controller.test.ts | 8 + src/ui/controller.ts | 76 ++- src/ui/drive-controller.ts | 174 +++++++ src/ui/drive-view.ts | 147 ++++++ src/ui/drive.dom.test.ts | 683 +++++++++++++++++++++++++ src/ui/settings-autosave.dom.test.ts | 14 + 22 files changed, 1819 insertions(+), 19 deletions(-) create mode 100644 src/application/drive-machine.test.ts create mode 100644 src/application/drive-machine.ts create mode 100644 src/domain/drive.ts create mode 100644 src/ui/drive-controller.ts create mode 100644 src/ui/drive-view.ts create mode 100644 src/ui/drive.dom.test.ts 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/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, } } From 2668b9f0322f235f38d20e2defabb5094b17dd97 Mon Sep 17 00:00:00 2001 From: m16khb Date: Sun, 11 Oct 2026 04:49:42 +0900 Subject: [PATCH 3/7] docs(drive): document Google Drive import and sync Why: galpi#12. Users need to know what Drive access Galpi asks for and what lands in their Drive; agents need the new ports, commands, and adapters on the architecture map. - README.md / README.en.md: Drive connection, import, sync, conflicts, and the drive.file scope. - INSTALL.md: Drive sign-in is handed back to the user like the other sign-ins. - docs/ARCHITECTURE.md, AGENTS.md, adapters/outbound/AGENTS.md: 35 IPC commands, 18 ports, the Drive use cases and adapters, and the auth-gateway token exchange. Tested: Not applicable (documentation); bun run check passes Not-tested: n/a --- AGENTS.md | 7 +++++-- INSTALL.md | 3 +++ README.en.md | 18 ++++++++++++++++++ README.md | 18 ++++++++++++++++++ docs/ARCHITECTURE.md | 18 ++++++++++-------- src-tauri/src/adapters/outbound/AGENTS.md | 2 ++ 6 files changed, 56 insertions(+), 10 deletions(-) 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/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/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 From 419f862ea3e00ac000a0e3a0d6112abfcb7f3450 Mon Sep 17 00:00:00 2001 From: m16khb Date: Sun, 11 Oct 2026 04:49:56 +0900 Subject: [PATCH 4/7] docs(issueops): record galpi#12 plan, gates, verification, and decisions Why: the IssueOps cycle for galpi#12 publishes its contract and evidence with the change: the approved plan and its review, the gate ledger, the verification report (including verify-stage review and QA fixes), and the live Drive smoke result. The decisions and pitfalls found on the way are recorded where the next change will look. - ADR: Drive tokens exchanged by auth-gateway; stateless sync through appProperties and sha256Checksum. - Cautions: set the job kind before rendering the job card; gate CHECKs with secret-like arguments are denied, and secret scans must include untracked files and match the real value shape; the desktop Picker consent's transient Google 500 clears on reload. - architecture/testing overviews: 18 ports, 35 commands, Drive adapters, Drive manual checks and the fake-server vs live smoke boundary. Tested: issueops gates check --write (12/12) Not-tested: n/a (records) --- ...google-drive-auth-gateway-appproperties.md | 27 +++ .issueops/adr/overview.md | 1 + .issueops/architecture/overview.md | 11 +- .issueops/cautions/2026-10-11-check.md | 16 ++ .../cautions/2026-10-11-picker-google-500.md | 16 ++ .../2026-10-11-setbusy-kind-renderjob.md | 17 ++ .issueops/cautions/overview.md | 3 + .issueops/evidence/task-12-drive-smoke.md | 29 +++ .issueops/issues/12/gates.md | 50 ++++ .issueops/issues/12/intent.md | 51 ++++ .issueops/issues/12/plan-review.md | 40 +++ .issueops/issues/12/plan.md | 228 ++++++++++++++++++ .issueops/issues/12/verification-report.md | 108 +++++++++ .issueops/testing/overview.md | 14 ++ 14 files changed, 607 insertions(+), 4 deletions(-) create mode 100644 .issueops/adr/2026-10-11-google-drive-auth-gateway-appproperties.md create mode 100644 .issueops/cautions/2026-10-11-check.md create mode 100644 .issueops/cautions/2026-10-11-picker-google-500.md create mode 100644 .issueops/cautions/2026-10-11-setbusy-kind-renderjob.md create mode 100644 .issueops/evidence/task-12-drive-smoke.md create mode 100644 .issueops/issues/12/gates.md create mode 100644 .issueops/issues/12/intent.md create mode 100644 .issueops/issues/12/plan-review.md create mode 100644 .issueops/issues/12/plan.md create mode 100644 .issueops/issues/12/verification-report.md 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-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..471724d 100644 --- a/.issueops/cautions/overview.md +++ b/.issueops/cautions/overview.md @@ -9,3 +9,6 @@ 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) 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..516dd97 --- /dev/null +++ b/.issueops/issues/12/gates.md @@ -0,0 +1,50 @@ +# 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) 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..c4b50a0 --- /dev/null +++ b/.issueops/issues/12/verification-report.md @@ -0,0 +1,108 @@ +# galpi#12 구현 검증 보고 + +- Lifecycle: `io-21fd3b1e1f06`, generation 2, branch `12-google-drive-sync`, base `4c24a2479fc4bf294ffeecc81fd06ea7438894fe` +- 계획: `.issueops/issues/12/plan.md` (sha256 `50c8de3550ebb0db6083592620d5c73c88224cbb8f6e01d1469fb421a07fcacb`) +- 게이트 원장: `.issueops/issues/12/gates.md` (G1~G12 충족) + +## 게이트 결과 + +| 게이트 | 명령 | 결과 | +|---|---|---| +| 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→덮어쓰기→회의록→해제 확인 | + +## 계획과 다르게 구현한 점 + +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은 아직 열려 있으나 엔드포인트는 배포되어 응답한다. 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`): From dab1c05997aa704b05e5c3fc236c930790fee9c7 Mon Sep 17 00:00:00 2001 From: m16khb Date: Sun, 11 Oct 2026 04:53:29 +0900 Subject: [PATCH 5/7] docs(issueops): link the draft PR in the issue 12 verification report Why: the completion record points at this report; it now names the draft PR it was published as and what stays out of the cycle (merge, deploy, cleanup). Tested: issueops remote verify-artifact (PR #13, base main, enhancement, m16khb) Not-tested: n/a (record) --- .issueops/issues/12/verification-report.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.issueops/issues/12/verification-report.md b/.issueops/issues/12/verification-report.md index c4b50a0..ea71435 100644 --- a/.issueops/issues/12/verification-report.md +++ b/.issueops/issues/12/verification-report.md @@ -106,3 +106,9 @@ SNR이 0.004 낮아진 것은 회귀 테스트 2개·application 테스트 2개 - 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)`), 그리고 이 게시 기록 커밋. +- 머지, 배포, 워크트리 정리는 이 사이클 범위 밖이다. From 7232f4a56fff549a523e8c48b53cf33f79655d72 Mon Sep 17 00:00:00 2001 From: m16khb Date: Sun, 11 Oct 2026 12:19:26 +0900 Subject: [PATCH 6/7] fix(drive): name downloaded Drive files the same way on every OS Why: PR #13's rust (windows-latest) job failed downloaded_names_are_sanitized_and_keep_the_extension: local_name parsed the Drive file name with std::path::Path, and Windows read "a:" in "a:b?.WAV" as a drive prefix and dropped it ("b.WAV"). macOS treated "\" as an ordinary character, so the two OSes disagreed on names like "C:\x\y.m4a". - local_name splits the name itself on "/" and "\" on every OS, skips empty and "." segments, falls back to "meeting" for "..", and splits the extension with the same rule as Path::file_stem. - paths::sanitized_stem holds the shared sanitize-or-"meeting" rule; meeting_stem keeps its behavior and now calls it. - The test adds Windows path syntax (C:\x\y.m4a, ..\..\boot.ini, C:, c:.wav) so the mismatch fails on macOS too. Tested: cargo test --all-targets (295 passed, 1 ignored); cargo clippy --all-targets -- -D warnings; cargo fmt --check Not-tested: Windows locally (verified by PR CI after push) --- src-tauri/src/adapters/outbound/drive/rest.rs | 35 ++++++++++++------- src-tauri/src/adapters/outbound/paths.rs | 22 ++++++++---- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/adapters/outbound/drive/rest.rs b/src-tauri/src/adapters/outbound/drive/rest.rs index a54e8c1..72d8ce8 100644 --- a/src-tauri/src/adapters/outbound/drive/rest.rs +++ b/src-tauri/src/adapters/outbound/drive/rest.rs @@ -5,7 +5,7 @@ //! result files again by querying them; only the root folder id is kept with //! the connection. -use super::super::paths::{AppPaths, canonical, meeting_stem}; +use super::super::paths::{AppPaths, canonical, sanitized_stem}; use super::Tuning; use crate::application::error::AppError; use crate::application::ports::{DriveFilesPort, LocalUpload, PickedFile, RemoteFolder}; @@ -194,18 +194,24 @@ fn multipart(metadata: &Value, mime: &str, content: &[u8]) -> (String, Vec) (format!("multipart/related; boundary={boundary}"), body) } -/// A file name safe on every platform, keeping the extension. +/// 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 path = Path::new(name); - let stem = meeting_stem(path); - match path - .extension() - .and_then(|extension| extension.to_str()) - .filter(|extension| { - !extension.is_empty() && extension.bytes().all(|byte| byte.is_ascii_alphanumeric()) - }) { - Some(extension) => format!("{stem}.{extension}"), - None => stem, + 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 } } @@ -730,6 +736,11 @@ mod tests { 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] diff --git a/src-tauri/src/adapters/outbound/paths.rs b/src-tauri/src/adapters/outbound/paths.rs index 69abdcc..ae7d088 100644 --- a/src-tauri/src/adapters/outbound/paths.rs +++ b/src-tauri/src/adapters/outbound/paths.rs @@ -182,12 +182,22 @@ pub async fn prepare_output_root(output_root: &Path) -> 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 From e6d3933613f1565200769dd21f5894731785a171 Mon Sep 17 00:00:00 2001 From: m16khb Date: Sun, 11 Oct 2026 12:19:26 +0900 Subject: [PATCH 7/7] docs(issueops): record the Windows Drive file name fix for issue 12 Why: the reopened cycle (generation 3) publishes its evidence with the fix, and the pitfall belongs where the next change will look. - gates.md: G13, downloaded Drive names are OS-independent. - verification-report.md: cause, RED/GREEN, side effects (cache name, local meeting folder and result stem, new Drive meeting folder name), review, and why web QA was not rerun. - Caution: parsing Drive names with Path differs on Windows. Tested: issueops gates check (13/13) Not-tested: n/a (records) --- .../cautions/2026-10-11-drive-path-windows.md | 13 +++++++++++++ .issueops/cautions/overview.md | 1 + .issueops/issues/12/gates.md | 4 ++++ .issueops/issues/12/verification-report.md | 17 +++++++++++++++-- 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 .issueops/cautions/2026-10-11-drive-path-windows.md 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/overview.md b/.issueops/cautions/overview.md index 471724d..2271f1f 100644 --- a/.issueops/cautions/overview.md +++ b/.issueops/cautions/overview.md @@ -12,3 +12,4 @@ Canonical index: [CAUTIONS.md](../CAUTIONS.md) - [작업 시작 시 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/issues/12/gates.md b/.issueops/issues/12/gates.md index 516dd97..9d4dca6 100644 --- a/.issueops/issues/12/gates.md +++ b/.issueops/issues/12/gates.md @@ -48,3 +48,7 @@ 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/verification-report.md b/.issueops/issues/12/verification-report.md index ea71435..0dadb1a 100644 --- a/.issueops/issues/12/verification-report.md +++ b/.issueops/issues/12/verification-report.md @@ -1,8 +1,8 @@ # galpi#12 구현 검증 보고 -- Lifecycle: `io-21fd3b1e1f06`, generation 2, branch `12-google-drive-sync`, base `4c24a2479fc4bf294ffeecc81fd06ea7438894fe` +- 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~G12 충족) +- 게이트 원장: `.issueops/issues/12/gates.md` (G1~G13 충족) ## 게이트 결과 @@ -20,6 +20,7 @@ | 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) | ## 계획과 다르게 구현한 점 @@ -112,3 +113,15 @@ SNR이 0.004 낮아진 것은 회귀 테스트 2개·application 테스트 2개 - 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`에 연결했다.