Articles

웹 화면을 그대로 macOS 앱 안에서 쓰고, 로그인은 브라우저에 맡기기

WhoIsLoafing의 macOS 앱은 웹 화면을 앱 안에서 그대로 열어요. 화면이 앱 안인지 알아보는 방법, 창 옮기기와 화면 설정을 앱에 알리는 방법, 로그인을 기본 브라우저에서 하고 앱으로 넘겨받는 흐름을 정리했어요.

WhoIsLoafing에는 macOS 앱이 있어요. 사이드바에서 내 작업을 오가고, 단축키로 움직이고, 레포를 둘러보며 바로 분석할 수 있어요. 그런데 앱의 화면은 따로 만들지 않았어요. 웹 서비스의 화면을 앱 안의 웹뷰로 그대로 열어요.

화면을 한 벌만 만들면 기능을 고칠 때 한 번만 고치면 돼요. 대신 웹 화면이 "지금 앱 안에 있는지"를 알고, 앱과 말을 주고받아야 해요. 이 글은 웹 쪽 코드에서 그걸 어떻게 했는지 정리한 것이에요.

앱 안인지 알아보기

앱의 웹뷰는 User-Agent 끝에 WhoIsLoafingApp/버전을 붙여요. 웹 화면은 이걸 보고 앱 안인지 판단해요.

export const isApp = typeof navigator !== 'undefined' && /WhoIsLoafingApp\//.test(navigator.userAgent)

export const appVersion = typeof navigator !== 'undefined' ? (/WhoIsLoafingApp\/([\d.]+)/.exec(navigator.userAgent)?.[1] ?? null) : null

앱 안이면 사이드바가 있는 앱 화면을 보여주고, 브라우저면 원래 웹 화면을 보여줘요. 같은 주소, 같은 코드인데 어디서 열었는지에 따라 모양이 달라져요.

버전도 같이 읽는 이유가 있어요. 웹 화면은 배포하면 바로 바뀌지만, 앱은 사용자가 업데이트해야 바뀌어요. 새 웹 화면이 예전 앱에 없는 기능을 부르면 아무 일도 안 일어나요. 그래서 버전을 비교해서, 앱이 그 기능을 지원할 때만 버튼을 보여줘요.

export function appAtLeast(minimum: string): boolean {
  if (!appVersion) return false
  const have = appVersion.split('.').map(Number)
  const need = minimum.split('.').map(Number)
  for (let index = 0; index < Math.max(have.length, need.length); index += 1) {
    const diff = (have[index] ?? 0) - (need[index] ?? 0)
    if (diff !== 0) return diff > 0
  }
  return true
}

버전 문자열을 그대로 비교하면 1.10이 1.9보다 작다고 나와요. 점으로 나눠서 숫자로 하나씩 비교해야 해요.

앱에 말 걸기

웹 화면에서 앱에 무언가를 부탁할 때는 웹뷰가 열어 둔 메시지 통로를 써요.

type AppMessage = { type: 'login' } | { type: 'drag' } | { type: 'zoom' } | { type: 'theme'; theme: AppTheme } | { type: 'backdrop'; on: boolean } | { type: 'update' }

export function postToApp(message: AppMessage) {
  ;(window as unknown as AppBridge).webkit?.messageHandlers?.whoisloafing?.postMessage(message)
}

보낼 수 있는 메시지를 타입으로 정해 두었어요. 로그인 창 열기, 창 옮기기, 창 키우기, 화면 모드 알리기, 업데이트 확인하기 같은 것들이에요. 브라우저에서는 이 통로가 없어서, 물음표 연산자 덕분에 아무 일도 일어나지 않고 넘어가요. 앱 안인지 매번 확인하지 않아도 오류가 나지 않아요.

창 옮기기

앱의 웹뷰는 창의 제목 표시줄까지 덮고 있어요. 화면이 깔끔해지지만, 사용자가 창을 잡고 옮길 곳이 사라져요.

그래서 웹 화면에서 "여기를 잡으면 창이 움직인다"는 곳에 data-app-drag를 붙였어요. 그곳을 누르면 앱에 알려서 창을 옮기고, 두 번 누르면 창을 키워요. macOS 창의 제목 표시줄과 같은 동작이에요.

const DRAG_AREA = '[data-app-drag]'
const NOT_DRAGGABLE = 'a, button, input, textarea, select, [role="menu"], [role="alertdialog"]'

function inDragArea(event: MouseEvent): boolean {
  const target = event.target
  return event.button === 0 && target instanceof Element && target.closest(DRAG_AREA) !== null && target.closest(NOT_DRAGGABLE) === null
}

잡을 수 있는 영역 안에 있어도 버튼, 링크, 입력창 위라면 창을 옮기지 않아요. 그렇지 않으면 사이드바의 버튼을 누를 때마다 창이 움찔거려요.

로그인은 기본 브라우저에서

앱 안에서 로그인 화면을 바로 보여줄 수도 있었어요. 하지만 기본 브라우저에서 로그인하게 했어요. 사용자는 앱이 비밀번호를 가로채지 않는다는 것을 주소창으로 확인할 수 있고, 브라우저에 이미 로그인되어 있으면 비밀번호를 다시 칠 필요도 없어요.

흐름은 이래요. 비밀값과 챌린지를 만들고 검사하는 일은 앱과 서버가 맡고, 이 글에서는 웹 화면이 맡은 부분을 중심으로 설명해요.

  1. 앱에서 로그인을 누르면, 앱이 비밀값(verifier)과 그로부터 만든 챌린지를 준비하고 기본 브라우저에서 /app-login 화면을 열어요.
  2. 브라우저에서 로그인한 뒤 "계속하기"를 누르면, 서버에 챌린지를 보내고 한 번만 쓸 수 있는 코드를 받아요.
  3. 브라우저가 whoisloafing://auth?code=...&state=... 주소를 열면 macOS가 앱을 깨워 코드를 넘겨요.
  4. 앱은 받은 코드와 처음에 만든 비밀값을 웹 화면에 이벤트로 넘기고, 웹 화면이 서버에 둘을 보내 로그인해요.

브라우저 쪽 화면은 받은 값부터 확인해요. 챌린지와 상태값이 정해진 모양이 아니면 아무것도 하지 않고 안내만 보여줘요.

const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/
const STATE_PATTERN = /^[A-Za-z0-9_-]{1,100}$/

"계속하기"를 누르기 전에는 앱이 무엇을 할 수 있게 되는지 목록으로 보여줘요. 내 계정 정보 보기, 내 작업 이어서 분석하기, 채팅 공유하고 지우기예요. 링크만 눌렀는데 몰래 로그인이 넘어가는 일이 없도록, 사용자가 직접 눌러야 넘어가요.

async function handOver() {
  const code = await createAppLoginCode(challenge)
  if (!code) {
    setError('앱으로 로그인을 넘기지 못했어요. 잠시 뒤에 다시 시도해 주세요.')
    return
  }
  setSent(true)
  window.location.href = `${APP_CALLBACK_URL}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
}

앱 쪽 웹 화면은 앱이 보내는 이벤트를 기다리다가, 코드와 비밀값이 모두 있을 때만 로그인을 시도해요.

const onLogin = async (event: Event) => {
  const detail = (event as CustomEvent<AppLoginDetail>).detail
  if (!detail?.code || !detail.verifier) return
  const outcome = await signInWithAppCode(detail.code, detail.verifier)
  if (!outcome.ok) setError(outcome.message)
}
window.addEventListener(APP_LOGIN_EVENT, onLogin)

코드만 훔쳐서는 로그인할 수 없게 하는 것이 이 흐름의 핵심이에요. 코드는 브라우저 주소로 오가니 다른 프로그램이 볼 수도 있어요. 하지만 비밀값은 브라우저를 거치지 않고, 브라우저는 챌린지만 서버에 전해요. 마지막에 코드와 비밀값을 함께 보내야 로그인이 되니, 코드만 가로챈 쪽은 쓸 수가 없어요. 널리 쓰이는 PKCE라는 방식과 같은 생각이에요.

앱의 세션은 브라우저의 세션과 따로 만들어져요. 브라우저에서 쓰던 토큰을 앱에 복사해 주는 것이 아니에요.

업데이트 목록

앱은 새 버전이 있는지 웹 서비스의 /download/appcast.xml을 읽어 확인해요. macOS 앱에서 널리 쓰는 Sparkle 형식이에요. 최신 버전 번호, 설치용 zip 주소, 그 파일의 서명이 들어 있어요.

<item>
  <title>1.1</title>
  <sparkle:shortVersionString>1.1</sparkle:shortVersionString>
  <enclosure url="https://whoisloafing.hidly.dev/download/WhoIsLoafing-1.1.zip" type="application/octet-stream" sparkle:edSignature="..." length="1111339" />
</item>

이 파일과 설치 파일은 웹 서비스와 같은 곳에 정적 파일로 올라가요. 앱을 배포할 서버를 따로 두지 않았어요.

한 가지 함정이 있었어요. 웹 서비스는 없는 주소로 들어오면 한 페이지 앱의 index.html을 대신 돌려줘요. 그래서 appcast.xml을 지우면 앱은 XML 대신 HTML을 받고, 업데이트 확인이 오류를 내요. "없음"이 아니라 "엉뚱한 파일"이 오는 거예요. 그래서 이 파일은 지우지 않는다는 규칙을 문서에 적어 뒀어요.

정리

  • 앱의 웹뷰가 User-Agent에 이름과 버전을 붙이면, 웹 화면은 앱 안인지와 앱이 지원하는 기능을 알 수 있어요.
  • 앱에 보내는 메시지는 타입으로 정해 두고, 브라우저에서는 조용히 아무 일도 하지 않게 해요.
  • 창을 덮는 웹뷰에서는 창을 잡을 영역을 표시하되, 버튼 위에서는 잡히지 않게 해요.
  • 로그인은 기본 브라우저에서 하고, 일회용 코드와 앱만 아는 비밀값으로 앱에 넘겨요.
  • 한 페이지 앱 옆에 둔 정적 파일은 지우면 HTML이 대신 나간다는 점을 기억해요.
다음 글 로그인 기록의 IP 주소 옆에 대략적인 위치 보여주기