[LazyClaude 심층 4] PWA + 72KB macOS .app — 패키지 매니저 없이 배포하기
Service Worker 한 장과 쉘 스크립트 한 줄로 "앱처럼" 만든다
LazyClaude 심층 시리즈 4편. 설치 라는 마찰 자체를 없애는 두 갈래 — PWA와 .app 번들.
왜 패키징이 어려운가
오픈소스 도구를 쓰려고 할 때 가장 큰 장벽은 기술적 어려움 이 아니라 설치 절차 다.
- “Homebrew 깔고…” — 안 깐 사람도 있다
- “npm 글로벌 설치…” — 권한 문제 흔함
- “Docker 띄우고…” — 일상 도구로 쓰기에는 무거움
LazyClaude는 이 마찰을 0에 가깝게 가져가기로 했다. 두 진입점을 동시에 만든다.
진입점 A — PWA (브라우저 → 앱)
웹 표준으로 풀 수 있는 가장 가벼운 방법.
1. Web App Manifest
// dist/manifest.webmanifest
{
"name": "LazyClaude",
"short_name": "LazyClaude",
"start_url": "/",
"display": "standalone",
"background_color": "#0b0b0c",
"theme_color": "#0b0b0c",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-mask.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
display: standalone이 핵심이다. 주소창 없이 앱 창 으로 뜬다.
2. Service Worker — 최소 구현
// dist/sw.js
const CACHE = "lzc-v1";
const ASSETS = ["/", "/manifest.webmanifest", "/icon-192.png"];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
});
self.addEventListener("fetch", (e) => {
const url = new URL(e.request.url);
if (url.pathname.startsWith("/api/")) {
e.respondWith(fetch(e.request));
return;
}
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
});
전략은 한 줄로:
/api/*— network-first (실시간성)- 나머지 — cache-first (앱 셸 즉시 표시)
3. HTML 등록
<link rel="manifest" href="/manifest.webmanifest">
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
</script>
이걸로 Chrome/Edge/Safari 모두에서 “홈 화면에 추가” / “앱 설치” 메뉴가 뜬다.
진입점 B — 72KB macOS .app 번들
PWA로 충분하지 않은 사용자를 위해, 진짜 독립 앱 도 같이 제공한다. Spotlight·Dock에서 검색되고, 더블클릭하면 서버 라이프사이클까지 자동.
.app 번들의 구조
macOS의 .app은 그냥 특정 구조의 디렉터리 다.
LazyClaude.app/
└── Contents/
├── Info.plist
├── MacOS/
│ └── LazyClaude
└── Resources/
└── lazyclaude.icns
Info.plist (최소)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key> <string>LazyClaude</string>
<key>CFBundleIdentifier</key> <string>dev.cmblir.lazyclaude</string>
<key>CFBundleVersion</key> <string>1.0</string>
<key>CFBundleExecutable</key> <string>LazyClaude</string>
<key>CFBundleIconFile</key> <string>lazyclaude</string>
<key>LSUIElement</key> <false/>
<key>NSHighResolutionCapable</key> <true/>
</dict>
</plist>
실행 파일 — 쉘 스크립트로 충분
Contents/MacOS/LazyClaude 자체를 실행 가능한 쉘 스크립트로 만든다. macOS는 텍스트 실행 파일도 실행 권한 만 있으면 그냥 실행한다.
#!/bin/bash
set -euo pipefail
REPO="$HOME/.lazyclaude"
PORT=8765
if curl -fsS "http://127.0.0.1:$PORT/api/version" >/dev/null 2>&1; then
open "http://127.0.0.1:$PORT/"
exit 0
fi
cd "$REPO"
nohup python3 server.py --port "$PORT" >/dev/null 2>&1 &
sleep 0.4
open "http://127.0.0.1:$PORT/"
필요한 것은:
- 실행 권한 (
chmod +x Contents/MacOS/LazyClaude) - macOS Gatekeeper 우회를 위한 quarantine 속성 제거 (
xattr -dr com.apple.quarantine LazyClaude.app)
설치 자동화
Makefile에 한 줄로:
install-mac:
./scripts/build-mac-app.sh
cp -R build/LazyClaude.app /Applications/
xattr -dr com.apple.quarantine /Applications/LazyClaude.app || true
@echo "Installed to /Applications. Spotlight: 'LazyClaude'."
총 번들 크기는 아이콘이 거의 다 차지한다 — 72KB 정도.
두 진입점이 동시에 의미 있는 이유
- PWA: 브라우저에 한 번만 들어가면 됨. 데스크톱·모바일 동일.
- .app: macOS 사용자가 서버 자체를 모르고도 쓸 수 있게. 종료 후에도 다음에 더블클릭이면 다시 시작.
같은 코드베이스, 같은 단일 HTML, 두 가지 접근 방식.
사이닝과 노타라이즈는?
오픈소스 + 로컬 전용에선 공식 코드 사이닝 이 필수가 아니다. Gatekeeper에 부딪히면:
- 우클릭 → 열기 (1회)
- 또는
xattr -dr com.apple.quarantine
상업 배포라면 Apple Developer 계정이 필요하지만, LazyClaude는 로컬 개발 도구 의 컨텍스트에서 충분히 직관적인 우회 를 받아들이기로 했다.
한 줄 요약
가장 좋은 설치 절차는 설치하지 않는 것이다. 다음으로 좋은 건 더블클릭 한 번.
다음 편: LazyClaude 심층 5 — Hyper Agent: 자기 개선 sub-agent의 메커니즘 이전 편: LazyClaude 심층 3 — 첫 페인트 차단 풀기