goodchuck 블로그

Windows 용 Electron 앱을 맥에서 개발하다 겪은 것 — 창 끌기 · 키체인 · 빈 화면 고친 기록

11분 읽기

gendesk 는 NovelAI 이미지 생성을 데스크탑에서 하려고 만든 Electron 앱이에요. 쓰는 쪽은 Windows 에 가까운데, 개발은 맥에서 하다 보니 맥에서만 생기는 문제를 직접 겪게 됐어요. 처음 띄우자마자 세 가지가 걸렸습니다.

  1. 창을 끌 수가 없다 — 이동도, 더블클릭 확대도 안 된다
  2. 켤 때마다 키체인 허락 창이 뜬다
  3. 개발 창이 빈 화면(about:blank)으로 뜬다

셋 다 Windows 만 생각하고 짰을 땐 안 보이던 문제예요. 이 글은 각각의 원인과 고친 코드, 그리고 반대로 맥에선 되는데 Windows 에서 깨지는 것들까지 정리한 기록입니다.

1. 창이 안 끌린다 — 제목 줄을 숨겼으면 끌 곳을 만들어 줘야 한다

맥에선 창 왼쪽 위 신호등 버튼만 남기고 제목 줄을 숨기는 모양(hiddenInset)이 자연스러워서 이렇게 열고 있었어요.

new BrowserWindow({
  titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
  // …
})

문제는 제목 줄이 없어지면 창을 잡을 곳도 같이 없어진다는 거예요. Windows 는 default 라 OS 제목 줄이 그대로 있어서 몰랐던 거고요.

Electron 에선 웹 화면의 어느 부분을 "창 끌기 영역"으로 쓸지 CSS 로 정해요. 그래서 맥일 때만 맨 위에 한 줄을 두고 끌기 영역으로 지정했어요(Tailwind 의 arbitrary property 로 썼습니다).

{platform === 'darwin' ? (
  <div className="h-9 select-none [-webkit-app-region:drag]">
    {document.title}
  </div>
) : null}

끌기 영역으로 지정하면 더블클릭 확대도 OS 가 알아서 처리해요. 주의할 점은 끌기 영역 안에 버튼이나 입력창을 두면 클릭이 안 먹는다는 것 — 그런 요소에는 [-webkit-app-region:no-drag] 를 따로 붙여야 해요.

2. 켤 때마다 키체인 허락 창 — safeStorage 는 OS 마다 다르게 동작한다

gendesk 는 NovelAI API 키를 파일로 저장하는데, Electron 의 safeStorage 로 암호화해서 저장하고 있었어요.

const data = safeStorage.encryptString(key)

safeStorage 는 OS 의 비밀 저장소를 빌려 써요.

OS쓰는 것사용자에게 묻나
WindowsDPAPI안 묻는다
macOS키체인앱이 키체인 항목에 접근할 때 허락을 받는다

맥에선 코드 서명이 없는 앱(개발본, 그리고 아직 서명하지 않은 배포본)이 이 허락을 기억하지 못해서, 켤 때마다 키체인 비밀번호 창이 떴어요. 개발 중엔 코드를 고칠 때마다 앱을 다시 켜는데, 그때마다 비밀번호를 넣어야 하니 꽤 불편했어요.

그래서 safeStorage 는 Windows 에서만 쓰고, 맥은 평문 파일에 나만 읽을 수 있는 권한(0600) 을 주는 쪽으로 바꿨어요.

// Windows 만 OS 암호화 — 맥은 서명 없는 앱이면 켤 때마다 키체인 허락 창이 뜬다
const useOsCrypto = () =>
  process.platform !== 'darwin' && safeStorage.isEncryptionAvailable()

function writeKey(key: string) {
  const data = useOsCrypto() ? safeStorage.encryptString(key) : Buffer.from(key, 'utf8')
  fs.writeFileSync(file, data, { mode: 0o600 })
  fs.chmodSync(file, 0o600) // 이미 있던 파일이면 mode 가 안 먹으니 한 번 더
}

한 가지 더 챙긴 게 있어요. 맥에 예전에 암호화해 둔 파일이 남아 있으면, 그걸 열려는 순간 또 키체인 창이 떠요. 그래서 읽을 때 암호문인지 먼저 보고(safeStorage 암호문은 v10·v11 로 시작해요), 맥에서 암호문이면 열지 않고 키를 한 번 다시 넣게 했어요.

const isEncrypted = (buf: Buffer) => /^v1\d/.test(buf.subarray(0, 3).toString('latin1'))

평문 + 0600 은 개인 컴퓨터에서 혼자 쓰는 로컬 앱이라 감수한 선택이에요. 키 파일을 앱 문서와 다른 파일로 둬서, 문서를 공유해도 키가 따라가지 않게 한 건 그대로 유지했고요. 나중에 맥 앱에 서명을 하게 되면 다시 safeStorage 로 돌아갈 수 있어요.

3. 개발 창이 빈 화면 — 첫 loadURL 이 about:blank 로 끝났다

gendesk 는 화면(Vite 개발 서버)과 Electron 이 따로 떠서, 개발 중엔 창이 http://localhost:3110 을 열어요. 그런데 맥에서 개발 창이 켜지자마자 터미널에 Network service crashed 가 한 번 찍히고, 창은 about:blank 에 멈춰 있는 일이 있었어요.

개발 창이 뜨는 순간 Electron 의 네트워크 서비스가 한 번 죽었다 살아나면서 첫 loadURL 이 날아간 거라, 고치는 방향은 단순했어요. 실패하면 다시 연다.

let retries = 0
win.webContents.on('did-fail-load', (_e, code, _desc, failedUrl, isMain) => {
  // -3 = 사용자가 멈춤(ERR_ABORTED)
  if (!isMain || code === -3 || retries >= 5 || !failedUrl.startsWith(url)) return
  retries += 1
  setTimeout(() => void win.loadURL(url).catch(() => {}), 500 * retries)
})
win.loadURL(url).catch(() => {})

// 네트워크 서비스가 죽으면 did-fail-load 없이 about:blank 에 남기도 했다
setTimeout(() => {
  if (!win.webContents.getURL().startsWith(url)) void win.loadURL(url).catch(() => {})
}, 2000)

포인트는 두 겹이에요.

  • did-fail-load 가 오면 점점 간격을 늘려 최대 5번 다시 연다
  • 그런데 네트워크 서비스가 죽은 경우엔 did-fail-load 조차 안 오고 빈 창에 남기도 해서, 2초 뒤에 주소가 그대로 비어 있으면 한 번 더 연다

배포본은 개발 서버가 아니라 같이 실려 온 파일(loadFile)을 열기 때문에 이 코드는 개발 때만 돌아요.

4. 화면 글자도 OS 를 따라가야 한다

여기까지가 "안 되는" 문제였다면, 이건 "되긴 하는데 어색한" 문제예요. 화면 곳곳에 Ctrl+Enter 로 생성, 탐색기에서 보기 가 박혀 있었어요. 맥 사용자에게 Ctrl 과 탐색기 는 틀린 안내죠.

실제 단축키는 이미 두 OS 를 다 받고 있었어요.

  • 메뉴 단축키는 CmdOrCtrl+… — 맥은 ⌘, Windows 는 Ctrl 로 Electron 이 바꿔 준다
  • 키 이벤트는 e.ctrlKey || e.metaKey

그래서 안내 문구만 OS 에 맞게 고르는 작은 함수를 두었어요. 플랫폼 값은 main 프로세스가 process.platform 을 상태에 실어 렌더러로 내려 줍니다.

export function platformText(platform: string | undefined) {
  const mac = platform === 'darwin'
  return {
    mod: mac ? '⌘' : 'Ctrl',          // 단축키 앞 키
    finder: mac ? 'Finder' : '탐색기', // 파일 관리자 이름
  }
}

main 쪽 우클릭 메뉴도 마찬가지로 Finder 에서 보기 / 탐색기에서 보기 를 나눴어요.

5. 반대 방향 — 맥에선 되는데 Windows 에서 깨지는 것

맥에서 고치다 보면 반대로 맥에서 만든 것이 Windows 에서 깨지는 경우도 챙겨야 해요. gendesk 에 같이 넣어 둔 것들이에요.

파일 · 폴더 이름은 Windows 기준으로

맥은 파일 이름에 : 나 ? 를 써도 되지만, Windows 는 \ / : * ? " < > | 를 못 쓰고 이름 끝의 점·공백도 안 돼요. 사용자가 입력한 이름으로 폴더를 만든다면 Windows 기준으로 다듬어야, 맥에서 만든 폴더를 Windows 로 옮겨도 열려요.

export function safeFolderName(name: string, fallback: string) {
  const s = String(name ?? '')
    .replace(/[\\/:*?"<>|\x00-\x1f]/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
    .replace(/[. ]+$/, '') // 윈도우는 끝의 점·공백을 못 쓴다
    .slice(0, 60)
  return s || fallback
}

경로 비교는 대소문자 · 구분자를 맞춰서

"이 파일이 허용된 폴더 안에 있나"를 문자열로 비교할 때, Windows 는 대소문자를 구분하지 않고 구분자도 \ 와 / 둘 다 써요. 그대로 비교하면 C:\Art 와 c:/art 가 다른 경로가 돼서 허용 목록이 새요. 또 단순 startsWith 는 /art-secret 을 /art 아래로 잘못 판정하니 구분자까지 확인해야 하고요.

function normalizeForCompare(p: string, platform: string) {
  let out = String(p)
  if (platform === 'win32') out = out.replace(/\//g, '\\').toLowerCase()
  return out.replace(/[\\/]+$/, '')
}

function isWithinRoot(abs: string, root: string, platform: string) {
  const sep = platform === 'win32' ? '\\' : '/'
  const a = normalizeForCompare(abs, platform)
  const r = normalizeForCompare(root, platform)
  return a === r || a.startsWith(r + sep)
}

Windows 에서 .cmd 를 spawn 하면 EINVAL

개발 스크립트에서 pnpm 같은 명령을 child_process.spawn 으로 띄우는데, Windows 에선 이게 .cmd 파일이라 shell 없이 띄우면 EINVAL 로 바로 죽어요(Node 18.20 · 20.12 의 보안 변경 이후). 그래서 shell: true 로 띄웠어요.

spawn(cmd, args, { stdio: 'inherit', shell: true })

shell: true 는 인자를 셸이 한 번 해석하니, 사용자 입력을 인자로 넘기는 곳엔 쓰지 않는 게 좋아요. 여기선 고정된 개발 명령만 띄워서 괜찮았어요.

6. 자동 업데이트 — 서명 없는 맥은 "알림만"

배포는 Windows(nsis)와 맥(dmg)을 둘 다 만들고 electron-updater 로 자동 업데이트를 붙였어요. 그런데 맥은 서명이 없으면 자동 교체가 안 돼요. 그래서 맥에선 새 버전을 받지 않고, 있다는 것만 알려서 릴리즈 페이지로 안내해요.

// 서명이 없는 mac 빌드는 자동 교체가 안 된다 — 받지 말고 알리기만 한다
const CAN_AUTO_INSTALL = process.platform !== 'darwin'

정리 — 두 OS 를 같이 쓸 때 체크리스트

이번에 겪은 걸 저장소 규칙 문서로 남겨서, 다른 Electron 앱(artdesk · noveldesk · folderbeam)에도 같은 규칙을 적용해요.

영역확인할 것
창 제목 줄맥에서 hiddenInset 이면 끌기 영역(-webkit-app-region: drag)을 둔다. 안의 버튼은 no-drag
비밀값 저장safeStorage 는 Windows 만. 서명 없는 맥 앱은 켤 때마다 키체인 창
개발 창loadURL 실패 · 빈 창이면 다시 연다
단축키메뉴는 CmdOrCtrl, 키 이벤트는 ctrlKey || metaKey, 안내 글자는 ⌘ / Ctrl
화면 문구Finder / 탐색기 를 OS 로 고른다
파일 이름만들 땐 Windows 금지 문자 · 끝 점/공백을 다듬는다
경로main 은 node:path 로 잇고, 비교는 platform 을 받아 대소문자 · 구분자를 맞춘다
프로세스 실행Windows 에서 .cmd 는 shell: true
자동 업데이트서명 전까지 맥은 알림만

규칙에는 보고 방식도 넣었어요. 한 OS 에서 확인했으면 한 OS 만 확인한 것이라, 지금은 작업을 마치고 보고할 때도 "macOS 에서만 확인 — Windows 미확인" 처럼 어느 쪽을 봤는지 적게 하고 있어요.