⚡ Electron2. 창 & 라이프사이클06 · protocol & 딥링크

06 · protocol & 딥링크

이 문서가 답하는 질문: myapp://oauth/callback?token=... 같은 딥링크는 어떻게 OS로 전달되고, 어떤 이벤트로 Electron 앱에 들어오나? 왜 macOS는 open-url, Windows는 argv, Linux는 .desktop 파일로 모두 다른가?


한 줄 답 (Pyramid Top)

커스텀 protocol(myapp://)은 OS의 URL handler 레지스트리에 등록되고, 그 결과는 플랫폼마다 다른 통로로 앱에 들어온다 — macOSapp.on('open-url')로, Windows/Linuxapp.on('second-instance')argv 끝에 URL이 문자열로 박혀서. 이 두 통로를 모두 듣지 않으면 반쪽짜리 딥링크가 된다.


Why — 왜 OS마다 다른가

웹 URL은 브라우저 한 곳이 처리한다 — 통일된 모델. OS의 custom URL scheme은 그렇게 통일되지 않았다.

OSURL handler 등록 방법호출 시 전달 방법
macOSInfo.plistCFBundleURLSchemes앱이 이미 떠 있으면 → open-url 이벤트
앱이 죽어 있었으면 → 실행 + open-url 이벤트
Windows레지스트리 HKEY_CLASSES_ROOT\myappmyapp.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'); // boolean

2) 수신 (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 — 전체 흐름 비교 표

시나리오macOSWindowsLinux
앱이 떠 있는 상태에서 딥링크open-url 이벤트second-instance argvsecond-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.setAsDefaultProtocolClientOS 차원의 등록이다. 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는 보통:

  1. 앱에서 시스템 브라우저를 연다 (shell.openExternal).
  2. 사용자가 브라우저에서 로그인.
  3. provider가 myapp://callback?token=...로 리다이렉트.
  4. 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 콜백의 한 가지 진입점을 얹으면 데스크톱 딥링크가 완성된다.