06 · protocol & 딥링크
이 문서가 답하는 질문:
myapp://oauth/callback?token=...같은 딥링크는 어떻게 OS로 전달되고, 어떤 이벤트로 Electron 앱에 들어오나? 왜 macOS는open-url, Windows는argv, Linux는.desktop파일로 모두 다른가?
한 줄 답 (Pyramid Top)
커스텀 protocol(
myapp://)은 OS의 URL handler 레지스트리에 등록되고, 그 결과는 플랫폼마다 다른 통로로 앱에 들어온다 — macOS는app.on('open-url')로, Windows/Linux는app.on('second-instance')의argv끝에 URL이 문자열로 박혀서. 이 두 통로를 모두 듣지 않으면 반쪽짜리 딥링크가 된다.
Why — 왜 OS마다 다른가
웹 URL은 브라우저 한 곳이 처리한다 — 통일된 모델. OS의 custom URL scheme은 그렇게 통일되지 않았다.
| OS | URL handler 등록 방법 | 호출 시 전달 방법 |
|---|---|---|
| macOS | Info.plist의 CFBundleURLSchemes 키 | 앱이 이미 떠 있으면 → open-url 이벤트앱이 죽어 있었으면 → 실행 + open-url 이벤트 |
| Windows | 레지스트리 HKEY_CLASSES_ROOT\myapp | myapp.exe "myapp://..." 로 실행→ process.argv 마지막에 URL |
| Linux | .desktop 파일의 MimeType=x-scheme-handler/myapp; | exec으로 실행 + argv로 URL |
Electron은
app.setAsDefaultProtocolClient('myapp')한 줄로 세 OS의 등록을 모두 처리한다. 하지만 수신 측 이벤트는 자동 통합하지 않는다 — 개발자가 두 통로를 모두 듣게 한다.
How — 등록과 수신
1) 등록 — setAsDefaultProtocolClient
const { app } = require('electron');
const path = require('node:path');
// 개발 중 (npm start)에는 다른 인자가 필요
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('myapp', process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
// 패키징된 앱
app.setAsDefaultProtocolClient('myapp');
}왜
process.defaultApp분기: 개발 중에는electron바이너리가main.js를 인자로 받아 실행되기 때문에, 등록을 그대로 두면 OS가 electron 바이너리를 호출하게 된다. 그러면 그 인자까지 같이 넣어줘야 정상 부팅.
등록 해제
app.removeAsDefaultProtocolClient('myapp');
app.isDefaultProtocolClient('myapp'); // boolean2) 수신 (macOS) — open-url
// app 준비 *전*에 등록해야 첫 launch의 open-url을 놓치지 않음!
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLink(url);
});
// 만약 앱이 죽어 있다가 깨어난 경우 — open-url이 ready보다 먼저 옴
app.whenReady().then(() => {
// 이때는 이미 open-url 핸들러에서 url을 받음
createWindow();
});함정:
app.whenReady().then()안에서on('open-url', ...)을 등록하면 첫 launch의 URL을 놓친다. macOS는ready전에 open-url 이벤트를 발사할 수 있다.
3) 수신 (Windows/Linux) — second-instance + argv
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit(); // 두 번째 인스턴스는 즉시 종료
} else {
app.on('second-instance', (event, argv, workingDirectory) => {
const url = argv.find(a => a.startsWith('myapp://'));
if (url) handleDeepLink(url);
// 기존 창 깨우기
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
// *최초* 실행 시에는 second-instance가 안 옴 — argv를 직접 봄
app.whenReady().then(() => {
createWindow();
const firstUrl = process.argv.find(a => a.startsWith('myapp://'));
if (firstUrl) handleDeepLink(firstUrl);
});
}4) 한 그림
5) 통합 핸들러 — 실무 코드
function handleDeepLink(url) {
const parsed = new URL(url); // 'myapp://oauth/callback?token=abc'
// parsed.protocol === 'myapp:'
// parsed.hostname === 'oauth'
// parsed.pathname === '/callback'
// parsed.searchParams.get('token') === 'abc'
if (parsed.hostname === 'oauth') {
completeOAuthLogin(parsed.searchParams.get('token'));
} else if (parsed.hostname === 'file') {
openFile(parsed.pathname);
}
}What — 전체 흐름 비교 표
| 시나리오 | macOS | Windows | Linux |
|---|---|---|---|
| 앱이 떠 있는 상태에서 딥링크 | open-url 이벤트 | second-instance argv | second-instance argv |
| 앱이 죽어 있는 상태에서 딥링크 | 앱 실행 → open-url (ready 전 가능) | 앱 실행 → process.argv에 URL 포함 | 동일 |
| 첫 launch 시 URL을 놓치지 않으려면 | will-finish-launching에서 open-url 핸들러 등록 | app.whenReady 안에서 process.argv 검사 | 동일 |
| Single instance lock 필수? | OS가 강제 (보통) | 필수 — 안 걸면 두 인스턴스 | 권장 |
| 등록 해제 | removeAsDefaultProtocolClient | 동일 (레지스트리 정리) | .desktop 갱신 |
What-if — 흔한 함정
| 함정 | 증상 | 해법 |
|---|---|---|
| Single instance lock 없이 protocol 등록 (Windows) | 딥링크 한 번에 두 인스턴스 | requestSingleInstanceLock 필수 |
whenReady 안에서 open-url 핸들러 등록 (macOS) | 첫 launch URL 누락 | top-level에서 등록 |
첫 launch에서 process.argv 검사 안 함 (Windows) | 죽어있던 앱은 URL을 못 받음 | whenReady에서 argv 스캔 |
process.defaultApp 분기 없이 개발 중 등록 | OS가 electron 바이너리를 호출 → 메인 스크립트 못 찾음 | 위 등록 코드 참고 |
| URL에 사용자 입력 포함 + 검증 없음 | 악성 myapp://...?cmd=rm -rf / → IPC로 흘러감 | URL 파싱 + 화이트리스트 |
| Linux .desktop 파일 갱신 안 함 | 등록은 했는데 OS가 모름 | 패키저(electron-builder)가 처리 |
event.preventDefault() 안 함 (macOS open-url) | 일부 OS 버전에서 기본 동작 발동 | 항상 preventDefault |
How (advanced) — Electron의 내부 protocol
app.setAsDefaultProtocolClient는 OS 차원의 등록이다. Electron 안에서는 또 다른 모델 — renderer가 요청하면 main이 응답하는 커스텀 protocol — 도 있다.
protocol.handle() (Electron 25+) — 권장
const { app, protocol, net } = require('electron');
app.whenReady().then(() => {
// app://local/* 요청을 디스크에서 읽어 응답
protocol.handle('app', (request) => {
const url = new URL(request.url);
const filePath = path.join(__dirname, 'dist', url.pathname);
return net.fetch('file://' + filePath);
});
win.loadURL('app://local/index.html');
});- 왜 쓰는가:
file://로 로드하면 origin이 null이 되어 CORS/CSP가 깨진다. 커스텀app://로 유효한 origin을 만들면 CSP/Service Worker가 동작. - VSCode·Notion 등이 오프라인 자산을 안전하게 로드하기 위해 사용.
protocol.registerSchemesAsPrivileged — 권한 부여
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: {
standard: true, // origin이 있는 URL처럼 취급
secure: true, // https처럼 — Service Worker 가능
supportFetchAPI: true,
corsEnabled: true,
}},
]);
// app.whenReady 전에 호출해야 함이 부분은 05 보안에서 file:// 대신 app:// 권장으로 다시 등장.
Insight — OAuth가 데스크톱에서 어려운 이유
“브라우저에서 시작해서 데스크톱 앱으로 돌아와야 한다” —
데스크톱 앱의 OAuth는 보통:
- 앱에서 시스템 브라우저를 연다 (
shell.openExternal).- 사용자가 브라우저에서 로그인.
- provider가
myapp://callback?token=...로 리다이렉트.- OS가 그 URL을 원래 앱에 전달.
이 4번 단계가 플랫폼마다 다른 통로다. macOS는
open-url, Windows는argv, Linux는 환경따라 다름. 게다가 singleton lock 없이는 Windows에서 앱이 두 개 뜸.그래서 Electron OAuth 라이브러리(
electron-oauth-helper,keytar+커스텀)는 이 다양한 통로를 한 핸들러로 통일하는 게 본업이다. 직접 짤 수도 있지만 함정의 수가 많아 — 라이브러리 사용을 권장한다.
다음 챕터
- 이제 창과 라이프사이클은 끝났다. 창이 main과 어떻게 대화하는가는 03 IPC에서.
- 외부에서 들어온 URL을 어떻게 안전하게 처리할까 — 05 보안에서 입력 검증 + 화이트리스트.
한 단락 요약
커스텀 protocol은 OS의 URL handler 레지스트리에 한 줄로 등록(
setAsDefaultProtocolClient)되지만, 수신 통로는 OS마다 다르다 — macOS의open-url, Windows/Linux의argv+second-instance. 두 통로를 모두 처리하고, Windows에서 single-instance lock을 거는 것이 기본기다. 그 위에 입력 검증과 OAuth 콜백의 한 가지 진입점을 얹으면 데스크톱 딥링크가 완성된다.