gendesk 는 NovelAI 이미지 생성을 데스크탑에서 하려고 만든 Electron 앱이에요. 쓰는 쪽은 Windows 에 가까운데, 개발은 맥에서 하다 보니 맥에서만 생기는 문제를 직접 겪게 됐어요. 처음 띄우자마자 세 가지가 걸렸습니다.
- 창을 끌 수가 없다 — 이동도, 더블클릭 확대도 안 된다
- 켤 때마다 키체인 허락 창이 뜬다
- 개발 창이 빈 화면(
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 | 쓰는 것 | 사용자에게 묻나 |
|---|---|---|
| Windows | DPAPI | 안 묻는다 |
| 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 미확인" 처럼 어느 쪽을 봤는지 적게 하고 있어요.