이 글은 Today I Learn 시리즈의 111번째 기록입니다. (총 133개)

웹으로 만든 KeyBloom을 네이티브 데스크톱 앱으로 만들기 시작했다. 목표는 “화면은 그대로, 소리만 네이티브”라서, 웹 코드를 갈아엎지 않고 껍데기만 씌우고 필요한 곳만 갈래를 트는 게 핵심이었다.


웹앱을 그대로 감싸기

Tauri가 하는 일 — 껍데기만

Tauri는 기존 정적 프론트엔드(빌드된 dist)를 OS의 내장 웹뷰(Windows면 WebView2)로 띄우는 네이티브 셸이다 — 웹 코드를 그대로 담아 설치형 앱으로 감싸는 껍데기라고 보면 된다. 웹앱을 액자에 끼운 셈으로, 그림(웹 코드)은 안 바뀌고 액자 덕에 설치형 앱이 된다. 그래서 HTML/JS/캔버스 코드는 하나도 안 바뀌고 앱 창 안에서 그대로 돈다. 설정은 “빌드 결과가 어디 있고 개발 서버가 어디냐”만 알려주면 된다.

// src-tauri/tauri.conf.json
{
  "build": {
    "frontendDist": "../dist",          // 기존 빌드 산출물
    "devUrl": "http://localhost:5173",  // 개발 중엔 vite 서버
    "beforeBuildCommand": "npm run build"
  }
}

웹 빌드·배포 파이프라인은 손도 안 댔다. 네이티브는 그 위에 얹히기만 한다.

실행 환경 감지 — 능력 플래그

웹에서 돌 때와 네이티브에서 돌 때를 코드가 구분해야 한다. 기존에 IS_FREE(빌드 티어)·FS_SUPPORTED(파일 API 지원)처럼 능력 플래그를 쓰고 있었으니, 세 번째로 IS_NATIVE를 같은 패턴으로 뒀다.

// src/platform.ts — Tauri v2는 웹뷰에 __TAURI_INTERNALS__를 주입한다. 웹엔 없다.
export const IS_NATIVE =
  typeof window !== "undefined" &&
  ("__TAURI_INTERNALS__" in window || "__TAURI__" in window);

이 플래그 하나로 라이브 토글 노출, 광고 로딩, 저장 방식 등을 갈랐다. 예를 들어 라이브 모드 토글은 원래 완성돼 있는데 웹 오디오 지연 때문에 숨겨뒀던 것 — 네이티브에서만 켠다.

modeBar.hidden = !IS_NATIVE; // 웹은 숨김, 네이티브만 노출

네이티브 파일 저장

웹 다운로드 vs 네이티브 “다른 이름으로 저장”

웹은 <a download>로 브라우저 기본 다운로드 폴더에 조용히 떨어뜨린다 — 사용자가 어디 저장됐는지 모른다. 네이티브에선 저장 대화상자로 위치를 직접 고르게 해야 한다. 방식은 JS가 경로를 받고, 실제 파일 쓰기는 Rust 커맨드가 맡는다.

// 저장: 네이티브면 대화상자 → Rust write_file, 웹이면 브라우저 다운로드
export async function downloadBlob(blob: Blob, filename: string, dir?: string): Promise<void> {
  if (IS_NATIVE) {
    const { invoke } = await import("@tauri-apps/api/core");
    let path: string | null;
    if (dir) path = `${dir}/${filename}`;            // 미리 지정한 폴더
    else {
      const { save } = await import("@tauri-apps/plugin-dialog");
      path = await save({ defaultPath: filename });  // 대화상자
    }
    if (!path) return;
    await invoke("write_file", { path, dataB64: await blobToBase64(blob) });
    return;
  }
  // ...웹: anchor 다운로드...
}

파일 쓰기는 Rust에서 — fs 플러그인 스코프 우회

Tauri의 fs 플러그인으로 파일을 쓰려면 “어느 경로에 써도 되는가”를 권한(scope)으로 일일이 허용해야 한다. 그런데 사용자가 대화상자로 고른 임의의 경로에 쓰려니 스코프가 걸리적거렸다. 그래서 fs 플러그인 대신 내 커맨드에서 std::fs로 직접 썼다 — 대화상자가 준 절대경로라 스코프 개념이 필요 없다.

// invoke로 바이너리를 안전하게 넘기려고 base64로 받는다
#[tauri::command]
fn write_file(path: String, data_b64: String) -> Result<(), String> {
    let bytes = general_purpose::STANDARD.decode(data_b64.as_bytes()).map_err(|e| e.to_string())?;
    std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
    Ok(())
}

내가 정의한 앱 커맨드는 플러그인 커맨드와 달리 별도 권한 없이 호출된다. 저장 폴더를 미리 지정해두면(dialog.open({directory:true})) 대화상자 없이 그 폴더에 타임스탬프 파일명으로 바로 쓴다.


라이브 소리만 네이티브로 — 심(seam)

가장 조심한 부분. 심(seam)은 두 방식을 잇는 이음매를 뜻하는데, 여기선 웹과 네이티브를 딱 한 곳에서만 가른다는 뜻이다. 소리를 전부 네이티브로 옮기면 파일 재생·영상 내보내기(브라우저 인코더가 Web Audio 렌더에 의존)가 깨진다. 그래서 라이브 연주가 소리를 내는 진입점 딱 한 곳만 네이티브로 가르고, 나머지는 Web Audio 그대로 뒀다.

라이브 소리는 AudioEngine.triggerNote 하나에서만 난다(파일 재생은 스케줄러, 내보내기는 오프라인 렌더 — 별개 경로). 그 지점에서만 분기한다.

// 라이브 입력 — 네이티브면 Rust 엔진으로, 웹이면 Web Audio
triggerNote(midi: number, velocity: number, duration: number): void {
  if (IS_NATIVE) { nativeAudio.noteOn(midi, velocity); return; }
  this.playAt(midi, velocity, duration, this.now());
}
// 건반 뗌 — 네이티브만 note-off(댐퍼). 웹은 고정 감쇠라 no-op
releaseNote(midi: number): void {
  if (IS_NATIVE) nativeAudio.noteOff(midi);
}

파일 스케줄러(scheduleNote)·정지·내보내기 렌더는 손대지 않았다. 진입점 하나만 갈라서, 웹은 기존 동작 그대로 유지되고 네이티브만 저지연 경로를 탄다.


요약

  • Tauri는 기존 정적 프론트를 웹뷰로 띄우는 껍데기다 — frontendDist/devUrl만 알려주면 웹 코드는 안 바뀐다.
  • 웹/네이티브 구분은 능력 플래그 하나(IS_NATIVE)로 — 기존 IS_FREE·FS_SUPPORTED와 같은 패턴.
  • 네이티브 저장은 JS가 대화상자로 경로를 받고 Rust write_file(std::fs)이 쓴다 — fs 플러그인 스코프를 우회, 바이너리는 base64로 넘김.
  • 소리를 통째로 옮기지 말고 라이브 진입점(triggerNote) 하나만 네이티브로 가른다 — 파일·내보내기는 Web Audio 그대로.

Series: Today I Learn

1 C++ 자료형(Data Type) 2 MD5 vs pHash 3 C++에서 함수의 선언과 정의 4 Tkinter padx, pady 5 메모리와 포인터 변수 6 Call by Value, Call by Reference, Call by Pointer 비교 7 const 8 Gemfile — Jekyll 프로젝트의 의존성 파일 9 kramdown-parser-gfm — Jekyll의 GFM 파서 10 파서(Parser) 11 AHU vs OHU 12 I might try it vs I'll try it 뉘앙스 차이 13 SESSION_EXPIRE_AT_BROWSER_CLOSE=True 14 configuration key 15 Git stash vs discard 16 subprocess.Popen으로 Windows 탐색기에 명령어를 전달 17 Post 잔디 분석하기 18 Google Sheets Sync 최적화 19 DSL (Domain Specific Language)과 GPL (General Purpose Language) 20 마크다운 표 그리는 방법 21 쿼리 파라미터(Query Parameter). 기존 QR코드 재활용 22 Django 보안 취약점 점검 및 수정 23 OOP Object-Oriented Programming 객체 지향 프로그래밍 24 Fernet 대칭 암호화 25 Jekyll 코드블록 안의 Liquid 태그 26 습관만들기 Pawbit TIL 1 27 insertOnConflictUpdate vs DoUpdate(target) 28 세션 필터 29 아코디언(Accordiaon) UI를 펼친상태로 만들기 30 input의 step 31 습관만들기 Pawbit TIL 2 32 습관만들기 Pawbit TIL 3 33 Word Cloud 34 Google Sheets를 데이터 버스로(with AppSheet) 35 Django 모델 텍스트 필드 자동 수집 패턴 36 localStorage로 섹션 토글 상태 유지 37 순차 ID 생성(`select_for_update()` + `max()` 조합) 38 역참조 검색과 distinct() 39 xlsx 다운로드와 로딩 오버레이 충돌 40 Android 파일 공유 MIME 타입 41 AssetManifest — Flutter 빌드 타임 asset 목록 런타임 조회 42 크레센도 Phaser + TypeScript TIL 1 43 크레센도 Phaser + TypeScript TIL 2 44 크레센도 Phaser + TypeScript TIL 3 45 크레센도 Phaser + TypeScript TIL 4 46 크레센도 Phaser + TypeScript TIL 5 47 여둘까 Office Layout TIL 1 48 습관만들기 Pawbit TIL 4 49 여둘까 Office Layout TIL 2 50 여둘까 Office Layout TIL 3 51 크레센도 Phaser + TypeScript TIL 6 52 UTF-8 BOM과 PowerShell 파일 쓰기 53 소리꽃 KeyBloom TIL 1 54 소리꽃 KeyBloom TIL 2 55 소리꽃 KeyBloom TIL 3 56 소리꽃 KeyBloom TIL 4 57 소리꽃 KeyBloom TIL 5 58 습관만들기 Pawbit TIL 5 59 여둘까 Office Layout TIL 4 60 소리꽃 KeyBloom TIL 6 61 소리꽃 KeyBloom TIL 7 62 소리꽃 KeyBloom TIL 8 63 메트로놈 Simple Metronome TIL 1 64 소리꽃 KeyBloom TIL 9 65 메트로놈 Simple Metronome TIL 2 66 소리꽃 KeyBloom TIL 10 67 습관만들기 Pawbit TIL 6 68 크레센도 Phaser + TypeScript TIL 7 69 메트로놈 Simple Metronome TIL 3 70 습관만들기 Pawbit TIL 7 71 정적 블로그 SEO 정비와 Pagefind 검색 도입 72 크레센도 Phaser + TypeScript TIL 8 73 메트로놈 Simple Metronome TIL 4 74 습관만들기 Pawbit TIL 8 75 안드로이드 AudioTrack 연속 재생, 실측 피커 정렬, 카메라 토치 플래시 76 메트로놈 Simple Metronome TIL 5 77 모래게임 Sandrop TIL 1 78 습관만들기 Pawbit TIL 9 79 메트로놈 Simple Metronome TIL 6 80 모래게임 Sandrop TIL 2 81 모래게임 Sandrop TIL 3 82 습관만들기 Pawbit TIL 10 83 습관만들기 Pawbit TIL 11 84 메트로놈 Simple Metronome TIL 7 85 모래게임 Sandrop TIL 4 86 모래게임 Sandrop TIL 5 87 습관만들기 Pawbit TIL 12 88 모래게임 Sandrop TIL 6 89 소리꽃 KeyBloom TIL 11 90 모래게임 Sandrop TIL 7 91 모래게임 Sandrop TIL 8 92 소리꽃 KeyBloom TIL 12 93 모래게임 Sandrop TIL 9 94 소리꽃 KeyBloom TIL 13 95 모래게임 Sandrop TIL 10 96 소리꽃 KeyBloom TIL 14 97 소리꽃 KeyBloom TIL 15 98 소리꽃 KeyBloom TIL 16 99 콘티온 Conti On TIL 1 100 소리꽃 KeyBloom TIL 17 101 콘티온 Conti On TIL 2 102 습관만들기 Pawbit TIL 13 103 콘티온 Conti On TIL 3 104 모래게임 Sandrop TIL 11 105 소리꽃 KeyBloom TIL 18 106 여둘까 Office Layout TIL 5 107 모래게임 Sandrop TIL 12 108 모래게임 Sandrop TIL 13 109 콘티온 Conti On TIL 4 110 모래게임 Sandrop TIL 14 111 소리꽃 KeyBloom TIL 19 읽는 중 112 소리꽃 KeyBloom TIL 20 113 소리꽃 KeyBloom TIL 21 114 소리꽃 KeyBloom TIL 22 115 습관만들기 Pawbit TIL 14 116 모래게임 Sandrop TIL 15 117 모래게임 Sandrop TIL 16 118 소리꽃 KeyBloom TIL 23 119 소리꽃 KeyBloom TIL 24 120 소리꽃 KeyBloom TIL 25 121 소리꽃 KeyBloom TIL 26 122 온실 GreenHouse TIL 1 123 콘티온 Conti On TIL 5 124 모래게임 Sandrop TIL 17 125 소리꽃 KeyBloom TIL 27 126 온실 GreenHouse TIL 2 127 콘티온 Conti On TIL 6 128 모래게임 Sandrop TIL 18 129 콘티온 Conti On TIL 7 130 모래게임 Sandrop TIL 19 131 소리꽃 KeyBloom TIL 28 132 모래게임 Sandrop TIL 20 133 모래게임 Sandrop TIL 21