프로젝트: AI 데스크톱 도구 만들기
만들 앱: 내 컴퓨터 안의 AI 작업대
이번 장에서는 실제로 쓸 만한 AI 데스크톱 도구를 만들어 보겠습니다. 이름은 가볍게 PromptDesk라고 부르겠습니다. PromptDesk는 사용자가 파일을 끌어다 놓으면 내용을 읽고, 미리 만들어 둔 프롬프트 템플릿을 적용해, OpenAI나 Gemini 같은 외부 AI API에 요청을 보내는 앱입니다.
완성 목표는 단순한 채팅 앱이 아닙니다. 웹 페이지에서 API를 한 번 호출해 보는 예제와도 다릅니다. 데스크톱 앱답게 API 키를 로컬에 안전하게 보관하고, 프롬프트 템플릿과 실행 기록을 저장하며, 긴 작업은 백그라운드에서 처리합니다. 업데이트 기능도 붙여 보안 패치와 기능 개선을 사용자에게 배포할 수 있게 만듭니다.
이 장에서 만들 앱의 핵심 흐름은 다음과 같습니다.
사용자가 설정 화면에서 자신의 AI API 키를 입력합니다.
사용자가 문서 파일을 앱 위로 드래그 앤 드롭합니다.
앱은 Rust 백엔드에서 파일을 읽고 텍스트를 추출합니다.
사용자가 고른 프롬프트 템플릿에 파일 내용과 변수를 끼워 넣습니다.
Rust command가 외부 AI API를 호출합니다.
진행률, 실패 원인, 완료 결과가 이벤트로 UI에 전달됩니다.
앱 업데이트와 프롬프트 템플릿 업데이트를 분리해 운영합니다.
Tauri가 특히 잘 어울리는 지점이 바로 여기에 있습니다. UI는 React, Vue, Svelte 같은 익숙한 웹 기술로 빠르게 만들고, API 키 저장·파일 접근·네트워크 호출·백그라운드 작업은 Rust 쪽에 맡깁니다. 웹 개발자의 생산성과 네이티브 앱의 안전한 경계를 동시에 얻는 셈입니다.

전체 구조 설계하기
PromptDesk의 구조는 네 덩어리로 나눌 수 있습니다.
Frontend UI
├─ 설정 화면
├─ 파일 드롭 영역
├─ 템플릿 선택 화면
└─ 작업 진행 상태 화면
Tauri / Rust Backend
├─ API 키 저장 command
├─ 파일 읽기 command
├─ AI 요청 command
├─ 백그라운드 작업 관리자
└─ 이벤트 발행기
Local Storage
├─ 프롬프트 템플릿
├─ 즐겨찾기
├─ 최근 실행 기록
└─ 사용자 설정
External AI API
├─ OpenAI
├─ Gemini
└─ 기타 호환 API여기서 가장 먼저 결정해야 할 것은 AI API 호출을 어디에서 할 것인가입니다. 프론트엔드에서 직접 fetch를 호출할 수도 있고, Rust command 뒤에 숨길 수도 있습니다. 둘 다 가능하지만 제품으로 배포할 앱이라면 기본값은 Rust 쪽을 추천합니다.
| 방식 | 장점 | 단점 | 추천 상황 |
|---|---|---|---|
| 프론트엔드에서 직접 호출 | 구현이 빠르고 브라우저 예제를 거의 그대로 쓸 수 있음 | API 키가 JS 번들, 개발자 도구, 네트워크 로그에 노출되기 쉬움 | 개인 실험, 내부 프로토타입 |
| Rust command에서 호출 | API 키를 프론트엔드에 넘기지 않아도 되고 로그 제어가 쉬움 | Rust에서 HTTP 요청과 에러 모델을 구현해야 함 | 배포용 데스크톱 앱 |
| 별도 로컬 서버나 sidecar 사용 | Python·Node·LangChain·로컬 모델 활용이 쉬움 | 프로세스 관리와 패키징이 복잡해짐 | RAG, 문서 파싱, 로컬 모델 연동 |
특히 API 키를 프론트엔드 코드에 하드코딩하는 방식은 피해야 합니다. Tauri 앱도 프론트엔드 JS 번들을 앱 패키지 안에 포함하므로, 사용자가 앱 번들을 열어 리소스를 뒤지면 문자열을 찾아낼 수 있습니다. 난독화는 시간을 조금 벌 뿐 보안 설계가 아닙니다. 실제 배포 앱에서는 사용자가 자신의 키를 입력하고, 앱은 그 값을 로컬 기기에만 저장하도록 만드는 편이 안전합니다.1
프로젝트 준비
프론트엔드는 React + TypeScript 기준으로 설명하지만 Vue나 Svelte를 써도 구조는 같습니다.
pnpm create tauri-app@latest prompt-desk
cd prompt-desk
pnpm tauri add store
pnpm tauri add dialog
pnpm tauri add fs
pnpm tauri add updater
pnpm tauri add processRust 쪽에서는 HTTP 요청을 위해 reqwest, 직렬화를 위해 serde, 작업 ID 생성을 위해 uuid를 사용하겠습니다. OS 보안 저장소를 직접 쓰고 싶다면 keyring 크레이트를 붙일 수 있습니다.
# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
uuid = { version = "1", features = ["v4", "serde"] }
keyring = "3"프론트엔드에서 Tauri API를 쓸 때는 v2 기준으로 @tauri-apps/api/core, @tauri-apps/api/event처럼 모듈을 나누어 가져옵니다. Tauri의 JavaScript API는 WebView와 Rust 백엔드를 연결하는 공식 진입점입니다.2
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'API 키는 프론트엔드에 넘기지 않습니다
설정 화면을 만들 때 흔히 이런 실수를 합니다.
const apiKey = 'sk-...'이 코드는 테스트할 때는 편합니다. 하지만 배포 순간부터 문제가 됩니다. 사용자가 앱을 설치하면 프론트엔드 번들도 함께 설치되고, 번들 안에 들어간 문자열은 영원히 비밀이 아닙니다.
PromptDesk에서는 다음 원칙을 지킵니다.
API 키는 소스 코드에 쓰지 않습니다.
API 키는 프론트엔드 상태에 오래 보관하지 않습니다.
저장된 키를 다시 화면에 평문으로 보여 주지 않습니다.
로그, 에러 메시지, 개발자 도구 콘솔에 키를 출력하지 않습니다.
AI API 호출은 Rust command에서 수행합니다.
가장 간단한 설정 화면은 비밀번호 입력창 하나로 충분합니다.
import { useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
export function ApiKeySettings() {
const [provider, setProvider] = useState<'openai' | 'gemini'>('openai')
const [key, setKey] = useState('')
const [saved, setSaved] = useState(false)
async function save() {
await invoke('save_api_key', { provider, key })
setKey('')
setSaved(true)
}
return (
<section>
<select value={provider} onChange={e => setProvider(e.target.value as 'openai' | 'gemini')}>
<option value='openai'>OpenAI</option>
<option value='gemini'>Gemini</option>
</select>
<input
type='password'
value={key}
placeholder='API 키를 붙여넣으세요'
onChange={e => setKey(e.target.value)}
/>
<button onClick={save}>저장</button>
{saved && <p>저장되었습니다. 키는 다시 표시하지 않습니다.</p>}
</section>
)
}Rust command는 OS 보안 저장소에 키를 저장합니다. keyring은 macOS Keychain, Windows Credential Manager, Linux Secret Service 같은 플랫폼별 저장소를 활용할 수 있습니다. 운영체제별 동작 차이는 테스트가 필요하지만, 민감한 값을 일반 JSON 파일에 평문으로 저장하는 것보다는 좋은 출발점입니다.
const KEY_SERVICE: &str = "com.example.promptdesk";
fn key_account(provider: &str) -> String {
format!("{}_api_key", provider)
}
#[tauri::command]
fn save_api_key(provider: String, key: String) -> Result<(), String> {
if key.trim().is_empty() {
return Err("API 키가 비어 있습니다.".into());
}
let entry = keyring::Entry::new(KEY_SERVICE, &key_account(&provider))
.map_err(|e| e.to_string())?;
entry.set_password(key.trim())
.map_err(|e| format!("API 키 저장에 실패했습니다: {e}"))
}
#[tauri::command]
fn has_api_key(provider: String) -> Result<bool, String> {
let entry = keyring::Entry::new(KEY_SERVICE, &key_account(&provider))
.map_err(|e| e.to_string())?;
Ok(entry.get_password().is_ok())
}중요한 점은 load_api_key를 프론트엔드에 공개하지 않는 것입니다. 키가 필요한 작업은 Rust 내부에서 바로 읽어서 사용합니다. 설정 화면은 "키가 저장되어 있는지"만 확인하면 됩니다. 사용자가 키를 바꾸고 싶다면 새 키를 입력하게 하면 됩니다.
로그를 남길 때도 조심해야 합니다. 가장 좋은 로그는 키를 아예 포함하지 않는 로그입니다. "OpenAI 요청 실패: 401" 정도면 디버깅에 충분한 경우가 많습니다.
프롬프트 템플릿을 데이터로 다룹니다
AI 도구가 매번 자유 입력창만 제공하면 사용자는 금방 지칩니다. 진짜 생산성은 반복 작업을 템플릿으로 만들 때 나옵니다.
PromptDesk의 템플릿은 이런 형태입니다.
type PromptTemplate = {
id: string
title: string
description: string
body: string
variables: Array<{
name: string
label: string
defaultValue?: string
}>
favorite: boolean
updatedAt: string
}예를 들어 "회의록 요약" 템플릿은 다음처럼 만들 수 있습니다.
다음 회의록을 읽고 핵심 내용을 정리해 주세요.
요약 형식:
- 결정된 사항
- 담당자별 할 일
- 다음 회의 전 확인할 위험 요소
회의록:
{{file_content}}
톤: {{tone}}{{file_content}}, {{tone}} 같은 값은 실행 시점에 치환됩니다. 여기서 주의할 점은 템플릿 치환을 단순 문자열 붙이기로만 생각하면 안 된다는 것입니다. 파일이 너무 크면 토큰 한도를 넘고, 사용자가 예상치 못한 변수명을 넣으면 빈 값이 들어갈 수 있습니다. 따라서 템플릿 실행 전에는 변수를 검증하고, 파일 크기와 최종 프롬프트 길이를 제한해야 합니다.
간단한 치환 함수는 프론트엔드에서 미리보기용으로 둘 수 있습니다.
export function renderTemplate(body: string, values: Record<string, string>) {
return body.replace(/{{s*([a-zA-Z0-9_]+)s*}}/g, (_, name) => {
return values[name] ?? ''
})
}하지만 최종 요청에 들어갈 프롬프트는 Rust 쪽에서 다시 한 번 렌더링하는 편이 좋습니다. 프론트엔드 미리보기는 사용자 경험을 위한 것이고, 백엔드 렌더링은 신뢰 가능한 실행을 위한 것입니다.
템플릿과 실행 기록은 처음에는 tauri-plugin-store로 충분합니다. 템플릿 수가 많아지고 검색, 태그, 날짜 필터, 전문 검색이 필요해지면 SQLite로 옮기면 됩니다.
import { load } from '@tauri-apps/plugin-store'
const store = await load('promptdesk.json')
await store.set('templates', templates)
await store.set('recentRuns', recentRuns)
await store.save()파일 드래그 앤 드롭 처리하기
AI 데스크톱 도구에서 드래그 앤 드롭은 작은 기능처럼 보이지만 체감 품질을 크게 바꿉니다. 사용자는 "파일 선택" 버튼을 누르고 폴더를 뒤지는 것보다, Finder나 Explorer에서 파일을 끌어다 놓는 방식을 훨씬 자연스럽게 느낍니다.
Tauri v2에서는 드래그 진입, 이동, 드롭, 이탈 이벤트를 처리할 수 있습니다. 실제 예제들에서는 tauri://drag-enter, tauri://drag-over, tauri://drag-drop, tauri://drag-leave 이벤트를 구독하거나, Rust 백엔드에서 WindowEvent::DragDrop을 받아 프론트엔드로 다시 전달하는 방식을 사용합니다.4
프론트엔드에서는 먼저 드롭 이벤트를 듣습니다.
import { useEffect, useState } from 'react'
import { listen } from '@tauri-apps/api/event'
type DroppedFile = {
path: string
name: string
}
export function DropZone() {
const [files, setFiles] = useState<DroppedFile[]>([])
const [dragging, setDragging] = useState(false)
useEffect(() => {
const cleanups: Array<() => void> = []
listen<any>('tauri://drag-enter', () => setDragging(true)).then(fn => cleanups.push(fn))
listen<any>('tauri://drag-leave', () => setDragging(false)).then(fn => cleanups.push(fn))
listen<any>('tauri://drag-drop', event => {
setDragging(false)
const paths = event.payload?.paths ?? []
setFiles(paths.map((path: string) => ({
path,
name: path.split(/[\/]/).pop() ?? path
})))
}).then(fn => cleanups.push(fn))
return () => cleanups.forEach(fn => fn())
}, [])
return (
<div className={dragging ? 'drop-zone active' : 'drop-zone'}>
<p>문서 파일을 여기에 끌어다 놓으세요</p>
<ul>{files.map(file => <li key={file.path}>{file.name}</li>)}</ul>
</div>
)
}운영 환경에서는 프론트엔드 이벤트만 믿지 말고 Rust command에서 다시 검증합니다. 허용할 확장자, 최대 파일 크기, 바이너리 파일 여부를 백엔드에서 확인해야 합니다.
use std::fs;
use std::path::Path;
fn read_text_file_safely(path: &str) -> Result<String, String> {
let path = Path::new(path);
let ext = path.extension()
.and_then(|v| v.to_str())
.unwrap_or("")
.to_lowercase();
let allowed = ["txt", "md", "csv", "json", "log"];
if !allowed.contains(&ext.as_str()) {
return Err("지원하지 않는 파일 형식입니다.".into());
}
let meta = fs::metadata(path).map_err(|e| e.to_string())?;
let max_bytes = 1024 * 1024 * 2;
if meta.len() > max_bytes {
return Err("파일이 너무 큽니다. 2MB 이하 파일만 처리합니다.".into());
}
fs::read_to_string(path)
.map_err(|_| "텍스트로 읽을 수 없는 파일입니다.".into())
}PDF, DOCX, 이미지 OCR까지 처리하고 싶다면 여기서 sidecar나 별도 파서가 필요해질 수 있습니다. 하지만 첫 버전에서는 텍스트 계열 파일만 안정적으로 처리하는 편이 낫습니다. 앱은 "무엇이든 다 되는 척"할 때보다 "되는 것을 확실히 잘할 때" 더 믿음직합니다.
AI 작업을 백그라운드로 보냅니다
AI 요청은 느릴 수 있습니다. 파일이 길면 전처리에 시간이 걸리고, API 서버가 바쁘면 응답이 늦어집니다. 사용량 제한에 걸리면 재시도해야 할 수도 있습니다. 이 작업을 UI에서 그대로 기다리게 만들면 앱이 멈춘 것처럼 보입니다.
따라서 PromptDesk는 작업을 시작하는 command와 진행 상황을 알려 주는 이벤트를 분리합니다.
Frontend
invoke('start_ai_job')
↓
Rust command
작업 ID 반환
↓
Background task
파일 읽기 → 프롬프트 생성 → AI 요청 → 결과 저장
↓
Events
ai-job://progress
ai-job://done
ai-job://failedRust command는 즉시 작업 ID를 반환하고, 실제 처리는 백그라운드에서 진행합니다.
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use uuid::Uuid;
#[derive(Deserialize)]
struct StartAiJobRequest {
provider: String,
template_body: String,
file_paths: Vec<String>,
variables: serde_json::Value,
}
#[derive(Serialize, Clone)]
struct JobEvent {
job_id: String,
status: String,
message: String,
progress: u8,
}
#[tauri::command]
async fn start_ai_job(
app: AppHandle,
req: StartAiJobRequest,
) -> Result<String, String> {
let job_id = Uuid::new_v4().to_string();
let job_id_for_task = job_id.clone();
tauri::async_runtime::spawn(async move {
let _ = app.emit("ai-job://progress", JobEvent {
job_id: job_id_for_task.clone(),
status: "reading".into(),
message: "파일을 읽는 중입니다.".into(),
progress: 10,
});
let result = run_ai_job(&req).await;
match result {
Ok(answer) => {
let _ = app.emit("ai-job://done", serde_json::json!({
"jobId": job_id_for_task,
"answer": answer
}));
}
Err(error) => {
let _ = app.emit("ai-job://failed", JobEvent {
job_id: job_id_for_task,
status: "failed".into(),
message: error,
progress: 100,
});
}
}
});
Ok(job_id)
}프론트엔드는 이벤트를 구독해 진행 상태를 바꿉니다.
useEffect(() => {
const cleanups: Array<() => void> = []
listen<JobEvent>('ai-job://progress', event => {
updateJob(event.payload.job_id, event.payload)
}).then(fn => cleanups.push(fn))
listen<any>('ai-job://done', event => {
markJobDone(event.payload.jobId, event.payload.answer)
}).then(fn => cleanups.push(fn))
listen<JobEvent>('ai-job://failed', event => {
markJobFailed(event.payload.job_id, event.payload.message)
}).then(fn => cleanups.push(fn))
return () => cleanups.forEach(fn => fn())
}, [])이벤트 기반 구조의 장점은 UI가 단순해진다는 것입니다. 프론트엔드는 "현재 이 작업이 어떤 상태인가"만 그리면 됩니다. Rust 백엔드는 파일 읽기, 재시도, API 호출, 취소 처리를 책임집니다.
실제 운영 오류를 상태 모델로 다룹니다
AI API 호출은 생각보다 자주 실패합니다. 네트워크가 끊길 수 있고, 키가 틀릴 수 있고, 사용량 제한에 걸릴 수 있고, 입력이 너무 길 수 있습니다. 실패를 alert('오류') 하나로 처리하면 사용자는 무엇을 해야 할지 모릅니다.
처음부터 작업 상태를 명확히 설계하세요.
| 상태 | 의미 | UI 표시 |
|---|---|---|
queued | 작업이 등록됨 | 대기 중 |
reading_files | 파일을 읽고 있음 | 파일명과 진행률 표시 |
rendering_prompt | 템플릿 변수 치환 중 | 프롬프트 준비 중 |
requesting | AI API 호출 중 | 취소 버튼 표시 |
retrying | 일시적 오류 후 재시도 중 | 몇 초 뒤 재시도 안내 |
completed | 정상 완료 | 결과 표시 및 복사 버튼 |
failed | 복구 불가능한 실패 | 원인과 해결 방법 표시 |
cancelled | 사용자가 취소함 | 취소됨 표시 |
오류도 종류별로 나누면 좋습니다.
enum AiJobError {
MissingApiKey,
InvalidApiKey,
NetworkTimeout,
RateLimited,
PromptTooLong,
UnsupportedFile,
ProviderError(String),
}사용자에게는 내부 에러 메시지를 그대로 보여 주지 말고 행동 가능한 문장으로 바꿉니다.
fn user_message(error: AiJobError) -> String {
match error {
AiJobError::MissingApiKey => "설정 화면에서 API 키를 먼저 입력해 주세요.".into(),
AiJobError::InvalidApiKey => "API 키가 올바르지 않습니다. 다시 확인해 주세요.".into(),
AiJobError::NetworkTimeout => "요청 시간이 초과되었습니다. 네트워크 상태를 확인한 뒤 다시 시도해 주세요.".into(),
AiJobError::RateLimited => "API 사용량 제한에 도달했습니다. 잠시 후 다시 시도해 주세요.".into(),
AiJobError::PromptTooLong => "입력 내용이 너무 깁니다. 파일을 줄이거나 요약 단계를 나누어 실행해 주세요.".into(),
AiJobError::UnsupportedFile => "지원하지 않는 파일 형식입니다.".into(),
AiJobError::ProviderError(_) => "AI 서비스에서 오류가 발생했습니다.".into(),
}
}타임아웃과 재시도도 중요합니다. 무한 재시도는 사용자의 API 비용을 낭비할 수 있습니다. 429 사용량 제한, 500번대 서버 오류처럼 일시적일 가능성이 있는 오류만 제한적으로 재시도하세요. 반대로 401 인증 실패, 400 잘못된 요청, 토큰 길이 초과는 재시도해도 해결되지 않습니다.
AI 요청 command 만들기
외부 AI API는 서비스마다 요청 형식이 다릅니다. 하지만 앱 내부에서는 공통 인터페이스를 만들면 좋습니다.
struct AiRequest {
provider: String,
prompt: String,
}
async fn call_ai(req: AiRequest) -> Result<String, String> {
match req.provider.as_str() {
"openai" => call_openai(req.prompt).await,
"gemini" => call_gemini(req.prompt).await,
_ => Err("지원하지 않는 AI 제공자입니다.".into()),
}
}OpenAI 호출은 대략 이런 모양이 됩니다. 실제 제품에서는 모델명, temperature, max tokens, organization header 등을 설정 화면에서 조절할 수 있게 확장하면 됩니다.
async fn call_openai(prompt: String) -> Result<String, String> {
let entry = keyring::Entry::new(KEY_SERVICE, "openai_api_key")
.map_err(|e| e.to_string())?;
let api_key = entry.get_password()
.map_err(|_| "OpenAI API 키가 저장되어 있지 않습니다.".to_string())?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| e.to_string())?;
let body = serde_json::json!({
"model": "gpt-4o-mini",
"messages": [
{ "role": "user", "content": prompt }
]
});
let res = client
.post("https://api.openai.com/v1/chat/completions")
.bearer_auth(api_key)
.json(&body)
.send()
.await
.map_err(|_| "AI 요청 중 네트워크 오류가 발생했습니다.".to_string())?;
let status = res.status();
let json: serde_json::Value = res.json().await
.map_err(|_| "AI 응답을 해석할 수 없습니다.".to_string())?;
if !status.is_success() {
return Err(format!("AI 서비스 오류: {status}"));
}
Ok(json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.to_string())
}이 코드는 학습용으로 단순화했습니다. 제품 코드에서는 응답 JSON 구조가 바뀌거나 에러 응답이 오는 경우를 더 꼼꼼히 처리해야 합니다. 그래도 중요한 원칙은 분명합니다. API 키는 Rust 내부에서 읽고, 프론트엔드는 키를 몰라도 됩니다.
권한은 작게 열고, 필요한 만큼만 추가합니다
PromptDesk는 프론트엔드에서 직접 파일을 마구 읽을 필요가 없습니다. 파일 드롭 이벤트로 경로를 받고, 실제 읽기는 Rust command에서 수행합니다. 따라서 capability도 최소한으로 시작합니다.
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "PromptDesk main window permissions",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"dialog:default",
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"process:allow-restart"
]
}프론트엔드에서 @tauri-apps/plugin-fs로 직접 파일을 읽는 구조를 선택했다면 fs 권한과 scope를 추가해야 합니다. 하지만 AI 앱에서는 민감한 로컬 파일을 다룰 가능성이 높으므로, 가능한 한 파일 접근을 Rust command로 좁히고 백엔드에서 검증하는 편이 낫습니다.
자동 업데이트 붙이기
AI 앱은 업데이트가 특히 중요합니다. 모델 API가 바뀔 수 있고, 프롬프트 처리 로직에 보안 문제가 생길 수 있으며, 파일 파서에서 취약점이 발견될 수도 있습니다. 사용자가 수동으로 새 버전을 내려받게 만들면 패치가 늦어집니다.
Tauri updater 플러그인은 업데이트 서버 또는 정적 JSON 파일을 통해 새 버전을 확인하고 설치할 수 있습니다. v2 updater는 업데이트 아티팩트 서명을 요구하며, 이 검증은 비활성화할 수 없습니다. 공개 키는 앱 설정에 넣고, 개인 키는 빌드할 때만 사용해야 합니다.5
먼저 서명 키를 만듭니다.
pnpm tauri signer generate -- -w ~/.tauri/promptdesk.keytauri.conf.json에는 updater 설정과 업데이트 아티팩트 생성을 추가합니다.
{
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "PUBLIC_KEY_CONTENT",
"endpoints": [
"https://github.com/example/promptdesk-releases/releases/latest/download/latest.json"
]
}
}
}빌드할 때는 개인 키를 환경 변수로 넘깁니다. .env 파일에 넣어 두는 방식은 updater 서명에는 맞지 않습니다.
export TAURI_SIGNING_PRIVATE_KEY="$HOME/.tauri/promptdesk.key"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="키를 만들 때 입력한 비밀번호"
pnpm tauri build프론트엔드에서는 업데이트 확인 버튼을 만들 수 있습니다. Tauri v2의 check()는 업데이트가 없으면 null을 반환하고, 업데이트가 있으면 Update 객체를 반환합니다.6
import { check } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
export async function checkForUpdates() {
const update = await check({ timeout: 30000 })
if (!update) {
return { status: 'up-to-date' }
}
await update.downloadAndInstall(event => {
if (event.event === 'Started') {
console.log('업데이트 다운로드 시작')
}
if (event.event === 'Progress') {
console.log('업데이트 다운로드 중', event.data.chunkLength)
}
if (event.event === 'Finished') {
console.log('업데이트 다운로드 완료')
}
})
await relaunch()
}자동 업데이트는 앱 바이너리와 네이티브 로직을 바꾸는 수단입니다. 하지만 프롬프트 템플릿은 꼭 앱 업데이트에 묶을 필요가 없습니다. 템플릿은 원격 JSON으로 따로 배포하고, 앱이 주기적으로 받아 로컬 store에 저장하게 만들 수 있습니다.
단, 원격 템플릿도 신뢰 경계 밖의 데이터입니다. 받아온 JSON은 스키마를 검증하고, 너무 긴 프롬프트나 위험한 내부 명령처럼 해석될 수 있는 필드는 거부해야 합니다. 바이너리 취약점은 updater로 고치고, 프롬프트 품질 개선은 템플릿 동기화로 빠르게 배포하는 식으로 나누면 운영이 편해집니다.
완성도를 높이는 작은 디테일
PromptDesk 같은 AI 도구는 기능보다 "믿고 맡길 수 있는 느낌"이 중요합니다. 사용자는 자신의 문서와 API 키를 앱에 맡깁니다. 그래서 UI 문구, 실패 처리, 저장 정책이 모두 신뢰를 만들어야 합니다.
다음 항목을 체크해 보세요.
API 키가 저장되었는지는 보여 주되, 키 자체는 다시 표시하지 않습니다.
드롭한 파일의 이름, 크기, 지원 여부를 실행 전에 보여 줍니다.
긴 작업에는 진행 상태와 취소 버튼을 제공합니다.
AI 요청 실패 메시지는 사용자가 다음 행동을 알 수 있게 씁니다.
프롬프트와 파일 내용은 사용자의 동의 없이 로그나 원격 서버에 저장하지 않습니다.
업데이트 실패가 앱 사용 자체를 막지 않게 합니다.
템플릿은 버전과 출처를 함께 관리합니다.
AI 앱에서 "작동한다"는 것은 충분하지 않습니다. 사용자는 이 앱이 자신의 데이터를 어떻게 다루는지 알고 싶어 합니다. 설정 화면에 저장 위치, API 키 처리 방식, 로그 정책을 짧게 설명하는 것만으로도 신뢰가 크게 올라갑니다.
마무리: AI 앱일수록 Tauri의 경계가 중요합니다
PromptDesk는 Tauri가 왜 AI 데스크톱 도구에 잘 맞는지 보여 줍니다. 프론트엔드는 웹 기술로 빠르게 만들고, 파일 접근과 API 키 저장은 Rust 백엔드로 숨기며, 긴 작업은 이벤트 기반으로 UI에 전달합니다. 자동 업데이트를 붙이면 API 변화와 보안 패치에도 대응할 수 있습니다.
중요한 것은 AI API 호출 자체가 아닙니다. API 키를 어디에 둘지, 사용자의 파일을 어떻게 읽을지, 긴 작업을 어떻게 보여줄지, 실패를 어떻게 설명할지, 업데이트를 어떻게 안전하게 배포할지가 제품의 품질을 결정합니다.
다음 장에서는 이 경험을 더 넓게 확장합니다. 하나의 생산성 앱을 데스크톱 우선으로 설계하고, 모바일 확장 가능성, 권한 설계, 플러그인 선택, 배포 전략까지 종합해 보겠습니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
