fix(electron): 중복 실행 방지 + 로딩 스플래시 — 재시작 빈 화면 해소
- 단일 인스턴스 잠금(requestSingleInstanceLock): 두 번째 실행 시 새 창 대신 기존 창 활성화 (중복 실행 시 라이브 사이트를 다시 로드하며 흰 화면이 오래 보이던 현상 해결) - 시작 시 전체 캐시 비우기 제거 → index.html 만 캐시버스트(?_=t), 해시 자산은 캐시 유지(빠른 재시작) - 로드되는 동안 스플래시(로딩) 창 표시, ready-to-show 후 메인 창 노출 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+80
-12
@@ -1,7 +1,13 @@
|
|||||||
// Slim Budget 데스크톱(Electron) 메인 프로세스.
|
// 돈돼지 가계부 데스크톱(Electron) 메인 프로세스.
|
||||||
// 라이브 사이트(https://app.sblog.kr)를 직접 로드한다 → 프론트 변경이 자동 반영(재설치 불필요),
|
// 라이브 사이트(https://app.sblog.kr)를 직접 로드한다 → 프론트 변경이 자동 반영(재설치 불필요),
|
||||||
// 같은 출처라 CORS 우회도 불필요. 배포된 웹 게이트가 Electron 을 감지해 안내페이지 대신 전체 앱을 띄운다.
|
// 같은 출처라 CORS 우회도 불필요. 배포된 웹 게이트가 Electron 을 감지해 안내페이지 대신 전체 앱을 띄운다.
|
||||||
const { app, BrowserWindow, shell, session, ipcMain, Menu } = require('electron')
|
//
|
||||||
|
// 로딩 UX: 예전에는 시작 시 HTTP 캐시를 통째로 비워 매번 전체 자산을 재다운로드 → 흰 화면이 오래 노출됐다.
|
||||||
|
// 이제 index.html 만 캐시버스트(?_=t)로 항상 최신을 받고, 해시된 정적 자산(JS/CSS)은 캐시를 유지해
|
||||||
|
// 재시작이 빠르다. 로드되는 동안에는 스플래시(로딩) 창을 띄워 빈 화면을 가린다.
|
||||||
|
// 또한 단일 인스턴스 잠금으로 중복 실행을 막아(두 번째 실행 시 사이트를 다시 로드하며 빈 화면이 보이던 문제),
|
||||||
|
// 이미 실행 중이면 기존 창을 활성화한다.
|
||||||
|
const { app, BrowserWindow, shell, ipcMain, Menu } = require('electron')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
@@ -14,6 +20,24 @@ app.setName('돈돼지 가계부')
|
|||||||
|
|
||||||
const APP_URL = 'https://app.sblog.kr'
|
const APP_URL = 'https://app.sblog.kr'
|
||||||
|
|
||||||
|
// 현재 메인 창 참조(중복 실행 시 활성화 대상).
|
||||||
|
let mainWindow = null
|
||||||
|
|
||||||
|
// 중복 실행 방지: 잠금을 못 얻으면 이미 실행 중 → 새 인스턴스를 즉시 종료한다.
|
||||||
|
// (두 번째 실행에서 라이브 사이트를 다시 로드하느라 빈 화면이 보이던 현상 해결)
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit()
|
||||||
|
} else {
|
||||||
|
// 두 번째 실행 시도가 들어오면 기존 창을 복원/활성화.
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
|
if (!mainWindow.isVisible()) mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 창 크기/위치를 userData 에 저장해 다음 실행 때 복원 (기본 해상도 고정 대신 사용자가 맞춘 크기 유지)
|
// 창 크기/위치를 userData 에 저장해 다음 실행 때 복원 (기본 해상도 고정 대신 사용자가 맞춘 크기 유지)
|
||||||
function stateFile() {
|
function stateFile() {
|
||||||
return path.join(app.getPath('userData'), 'window-state.json')
|
return path.join(app.getPath('userData'), 'window-state.json')
|
||||||
@@ -35,6 +59,23 @@ function saveState(win) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 스플래시(로딩) 화면 — 라이브 사이트가 첫 렌더될 때까지 빈 흰 화면 대신 표시.
|
||||||
|
function splashHtml() {
|
||||||
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
|
||||||
|
<style>html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center;
|
||||||
|
background:#1a1a1a;color:#eee;font-family:system-ui,'Segoe UI',sans-serif;-webkit-user-select:none;user-select:none}
|
||||||
|
.box{text-align:center}
|
||||||
|
.pig{font-size:46px;line-height:1}
|
||||||
|
.name{margin-top:10px;font-size:17px;font-weight:700;letter-spacing:-0.01em}
|
||||||
|
.spin{margin:18px auto 0;width:26px;height:26px;border:3px solid rgba(255,255,255,.2);
|
||||||
|
border-top-color:#00bc7e;border-radius:50%;animation:r .8s linear infinite}
|
||||||
|
@keyframes r{to{transform:rotate(360deg)}}
|
||||||
|
.tip{margin-top:12px;font-size:12px;opacity:.5}</style></head>
|
||||||
|
<body><div class="box"><div class="pig">🐷</div><div class="name">돈돼지 가계부</div>
|
||||||
|
<div class="spin"></div><div class="tip">불러오는 중…</div></div></body></html>`
|
||||||
|
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
|
||||||
|
}
|
||||||
|
|
||||||
// 오프라인/로드 실패 시 안내 화면 (다시 시도 버튼)
|
// 오프라인/로드 실패 시 안내 화면 (다시 시도 버튼)
|
||||||
function offlineHtml() {
|
function offlineHtml() {
|
||||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
|
||||||
@@ -57,6 +98,7 @@ function createWindow() {
|
|||||||
y: s?.y,
|
y: s?.y,
|
||||||
minWidth: 360,
|
minWidth: 360,
|
||||||
minHeight: 560,
|
minHeight: 560,
|
||||||
|
show: false, // 첫 렌더(ready-to-show) 전까지 숨김 → 빈 흰 화면 노출 방지(스플래시가 대신 보임)
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
title: '돈돼지 가계부',
|
title: '돈돼지 가계부',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -68,17 +110,49 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (s?.maximized) win.maximize()
|
if (s?.maximized) win.maximize()
|
||||||
|
mainWindow = win
|
||||||
|
|
||||||
|
// 로딩 스플래시(프레임 없는 작은 창) — 메인이 준비되면 닫는다.
|
||||||
|
const splash = new BrowserWindow({
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
frame: false,
|
||||||
|
resizable: false,
|
||||||
|
movable: false,
|
||||||
|
skipTaskbar: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
title: '돈돼지 가계부',
|
||||||
|
})
|
||||||
|
splash.loadURL(splashHtml())
|
||||||
|
splash.center()
|
||||||
|
|
||||||
|
// 메인 창을 한 번만 노출하고 스플래시를 정리(이벤트 중복/지연 대비).
|
||||||
|
let revealed = false
|
||||||
|
const reveal = () => {
|
||||||
|
if (revealed) return
|
||||||
|
revealed = true
|
||||||
|
if (!win.isDestroyed()) win.show()
|
||||||
|
if (!splash.isDestroyed()) splash.destroy()
|
||||||
|
}
|
||||||
|
win.once('ready-to-show', reveal)
|
||||||
|
// 안전장치: 로드가 비정상적으로 지연돼도 12초 후엔 메인 창을 띄운다.
|
||||||
|
setTimeout(reveal, 12000)
|
||||||
|
|
||||||
// 기본 메뉴바 제거(앱 자체 내비게이션 사용)
|
// 기본 메뉴바 제거(앱 자체 내비게이션 사용)
|
||||||
win.removeMenu()
|
win.removeMenu()
|
||||||
win.loadURL(APP_URL)
|
// index.html 만 캐시버스트(?_=t) → 항상 최신 프론트. 해시 자산은 캐시 유지 → 빠른 재시작.
|
||||||
|
win.loadURL(APP_URL + '/?_=' + Date.now())
|
||||||
|
|
||||||
// 닫을 때 창 크기/위치 저장 → 다음 실행에 복원
|
// 닫을 때 창 크기/위치 저장 → 다음 실행에 복원
|
||||||
win.on('close', () => saveState(win))
|
win.on('close', () => saveState(win))
|
||||||
|
|
||||||
// 메인 프레임 로드 실패(오프라인 등) 시 안내 화면. -3(aborted, 리다이렉트 등)은 무시.
|
// 메인 프레임 로드 실패(오프라인 등) 시 안내 화면. -3(aborted, 리다이렉트 등)은 무시.
|
||||||
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||||
if (isMainFrame && errorCode !== -3) win.loadURL(offlineHtml())
|
if (isMainFrame && errorCode !== -3) {
|
||||||
|
win.loadURL(offlineHtml())
|
||||||
|
reveal() // 오프라인 안내도 보이도록 메인 창 노출
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 외부(앱 외 도메인) 링크는 기본 브라우저로, 앱 내 링크는 창에서 처리
|
// 외부(앱 외 도메인) 링크는 기본 브라우저로, 앱 내 링크는 창에서 처리
|
||||||
@@ -110,14 +184,8 @@ ipcMain.handle('window:getSize', (e) => {
|
|||||||
return win ? win.getSize() : [0, 0]
|
return win ? win.getSize() : [0, 0]
|
||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(() => {
|
||||||
// 시작 시 HTTP 캐시 비움 → 라이브 사이트의 최신 프론트를 항상 로드(옛 HTML 캐시로 인한 미반영 방지).
|
// 캐시는 비우지 않는다(빠른 재시작). 최신 프론트는 index.html 캐시버스트로 보장.
|
||||||
// localStorage(로그인 세션)는 캐시가 아니라 영향 없음.
|
|
||||||
try {
|
|
||||||
await session.defaultSession.clearCache()
|
|
||||||
} catch {
|
|
||||||
/* 캐시 클리어 실패 무시 */
|
|
||||||
}
|
|
||||||
createWindow()
|
createWindow()
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
|||||||
Reference in New Issue
Block a user