From ead2ba6b3a201fbf459ce2ed1255fbc67839e54b Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 17:54:26 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=B1=20=EC=9C=84=EC=A0=AF=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B5=AC=EB=B6=84=EC=84=A0=20=EB=A6=AC=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=20=EC=95=88=EB=90=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/package.json | 1 + app/src/services/appUpdate.ts | 78 +++++++++++++-- .../service/MobileAppReleaseService.java | 30 +++++- frontend/public/install.html | 7 +- .../floatingWidgets/FloatingWidgetWindow.tsx | 50 +++++++--- .../src/hooks/useFloatingWidgetGridSplit.ts | 97 +++++++++++-------- frontend/src/styles/floatingWidget.css | 14 +++ package-lock.json | 10 ++ scripts/build-mobile-apk.sh | 7 ++ 9 files changed, 229 insertions(+), 65 deletions(-) diff --git a/app/package.json b/app/package.json index 1317de7..4ec1dff 100644 --- a/app/package.json +++ b/app/package.json @@ -14,6 +14,7 @@ "dependencies": { "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", + "@capacitor/browser": "^7.0.5", "@capacitor/core": "^7.0.0", "@capacitor/haptics": "^7.0.0", "@capacitor/ios": "^7.0.0", diff --git a/app/src/services/appUpdate.ts b/app/src/services/appUpdate.ts index c23c4f0..d397c5d 100644 --- a/app/src/services/appUpdate.ts +++ b/app/src/services/appUpdate.ts @@ -2,7 +2,9 @@ * Android 앱 내 업데이트 — /api/mobile-app/info 버전 비교 후 APK 다운로드 */ import { App } from '@capacitor/app'; +import { Browser } from '@capacitor/browser'; import { Capacitor } from '@capacitor/core'; +import { Preferences } from '@capacitor/preferences'; import { isRemoteVersionNewer, loadMobileAppReleaseInfo, @@ -16,7 +18,8 @@ export interface AppUpdateCheckResult { release?: MobileAppReleaseInfoDto | null; } -const skipKey = (remote: string) => `gc_skip_update_${remote}`; +const skipSessionKey = (remote: string) => `gc_skip_update_${remote}`; +const pendingInstallKey = 'gc_pending_update_install'; export async function getCurrentAppVersion(): Promise { if (!Capacitor.isNativePlatform()) { @@ -30,10 +33,49 @@ export async function getCurrentAppVersion(): Promise { } } +async function getPendingInstallVersion(): Promise { + try { + const { value } = await Preferences.get({ key: pendingInstallKey }); + return value?.trim() || null; + } catch { + return null; + } +} + +async function setPendingInstallVersion(remoteVersion: string): Promise { + try { + await Preferences.set({ key: pendingInstallKey, value: remoteVersion }); + } catch { + /* ignore */ + } +} + +async function clearPendingInstallVersion(): Promise { + try { + await Preferences.remove({ key: pendingInstallKey }); + } catch { + /* ignore */ + } +} + +/** 설치 완료 시 pending 플래그 제거 */ +async function reconcilePendingInstall(currentVersion: string, remoteVersion?: string): Promise { + const pending = await getPendingInstallVersion(); + if (!pending) return; + if (remoteVersion && !isRemoteVersionNewer(currentVersion, remoteVersion)) { + await clearPendingInstallVersion(); + return; + } + if (!isRemoteVersionNewer(currentVersion, pending)) { + await clearPendingInstallVersion(); + } +} + export async function checkForAppUpdate(): Promise { const currentVersion = await getCurrentAppVersion(); const release = await loadMobileAppReleaseInfo(); const remoteVersion = release?.version?.trim(); + await reconcilePendingInstall(currentVersion, remoteVersion); const updateAvailable = Boolean( release?.available && remoteVersion @@ -49,14 +91,19 @@ export async function openApkDownload(release?: MobileAppReleaseInfoDto | null): if (!url) return false; if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') { - const opened = window.open(url, '_blank', 'noopener,noreferrer'); - if (opened) return true; + try { + await Browser.open({ url, presentationStyle: 'popover' }); + return true; + } catch { + /* Browser 플러그인 실패 시 anchor fallback */ + } } const a = document.createElement('a'); a.href = url; a.rel = 'noopener'; a.download = info?.fileName ?? 'goldenchart-android.apk'; + a.target = '_blank'; document.body.appendChild(a); a.click(); a.remove(); @@ -65,7 +112,7 @@ export async function openApkDownload(release?: MobileAppReleaseInfoDto | null): export function isUpdateDismissed(remoteVersion: string): boolean { try { - return sessionStorage.getItem(skipKey(remoteVersion)) === '1'; + return sessionStorage.getItem(skipSessionKey(remoteVersion)) === '1'; } catch { return false; } @@ -73,13 +120,24 @@ export function isUpdateDismissed(remoteVersion: string): boolean { export function dismissUpdate(remoteVersion: string): void { try { - sessionStorage.setItem(skipKey(remoteVersion), '1'); + sessionStorage.setItem(skipSessionKey(remoteVersion), '1'); } catch { /* ignore */ } } -/** 업데이트 확인 후 confirm → APK 다운로드. skipIfDismissed면 세션 내 거부 버전 생략 */ +/** 다운로드 시작 후 같은 버전 재알림 방지 (설치 완료 전) */ +export async function isUpdatePendingInstall(remoteVersion: string): Promise { + const pending = await getPendingInstallVersion(); + return pending === remoteVersion; +} + +export async function shouldSkipUpdatePrompt(remoteVersion: string): Promise { + if (isUpdateDismissed(remoteVersion)) return true; + return isUpdatePendingInstall(remoteVersion); +} + +/** 업데이트 확인 후 confirm → APK 다운로드. skipIfDismissed면 세션 거부·다운로드 대기 중 생략 */ export async function promptAppUpdate(options?: { skipIfDismissed?: boolean; }): Promise { @@ -92,7 +150,7 @@ export async function promptAppUpdate(options?: { return false; } - if (options?.skipIfDismissed && isUpdateDismissed(check.remoteVersion)) { + if (options?.skipIfDismissed && await shouldSkipUpdatePrompt(check.remoteVersion)) { return false; } @@ -107,11 +165,12 @@ export async function promptAppUpdate(options?: { return false; } + await setPendingInstallVersion(check.remoteVersion); await openApkDownload(check.release); return true; } -/** 설정 화면 — 수동 업데이트 확인 */ +/** 설정 화면 — 수동 업데이트 확인 (pending 스킵 무시) */ export async function checkAndPromptAppUpdate(): Promise { const result = await checkForAppUpdate(); if (!result.updateAvailable) { @@ -122,7 +181,8 @@ export async function checkAndPromptAppUpdate(): Promise { `새 버전 v${result.remoteVersion} 사용 가능.\n` + `현재 v${result.currentVersion}\n\nAPK를 다운로드하시겠습니까?`, ); - if (ok) { + if (ok && result.remoteVersion) { + await setPendingInstallVersion(result.remoteVersion); await openApkDownload(result.release); } return result; diff --git a/backend/src/main/java/com/goldenchart/service/MobileAppReleaseService.java b/backend/src/main/java/com/goldenchart/service/MobileAppReleaseService.java index f9c5baa..20ffb21 100644 --- a/backend/src/main/java/com/goldenchart/service/MobileAppReleaseService.java +++ b/backend/src/main/java/com/goldenchart/service/MobileAppReleaseService.java @@ -57,12 +57,11 @@ public class MobileAppReleaseService { Instant updated = Files.getLastModifiedTime(file).toInstant(); String base = normalizeBase(publicBaseUrl); String downloadUrl = base + "/api/mobile-app/download/android"; + String version = resolveApkVersion(file); return MobileAppReleaseInfoDto.builder() .available(true) .fileName(file.getFileName().toString()) - .version(configuredVersion != null && !configuredVersion.isBlank() - ? configuredVersion - : DT_FMT.format(updated)) + .version(version) .sizeBytes(size) .updatedAt(DT_FMT.format(updated)) .downloadUrl(downloadUrl) @@ -141,4 +140,29 @@ public class MobileAppReleaseService { } return publicBaseUrl.replaceAll("/+$", ""); } + + /** APK와 함께 기록된 .version sidecar → .env configuredVersion → 파일 수정 시각 */ + private String resolveApkVersion(Path apkFile) { + String apkName = apkFile.getFileName().toString(); + Path sidecar = apkFile.getParent().resolve( + apkName.replaceAll("(?i)\\.apk$", "") + ".version"); + try { + if (Files.isRegularFile(sidecar)) { + String fromFile = Files.readString(sidecar).trim(); + if (!fromFile.isBlank()) { + return fromFile; + } + } + } catch (IOException e) { + log.warn("[mobile-app] read version sidecar failed: {}", e.getMessage()); + } + if (configuredVersion != null && !configuredVersion.isBlank()) { + return configuredVersion.trim(); + } + try { + return DT_FMT.format(Files.getLastModifiedTime(apkFile).toInstant()); + } catch (IOException e) { + return "unknown"; + } + } } diff --git a/frontend/public/install.html b/frontend/public/install.html index 9e05ab6..12d606c 100644 --- a/frontend/public/install.html +++ b/frontend/public/install.html @@ -71,10 +71,11 @@ '

' + (info.version ? 'v' + info.version + ' · ' : '') + (info.sizeBytes ? (info.sizeBytes / 1048576).toFixed(1) + ' MB' : '') + '

' + 'APK 다운로드' + - '
  1. 다운로드 후 APK 파일을 열어 설치
  2. ' + + '
    1. 다운로드 후 APK 파일을 열어 설치를 완료하세요
    2. ' + '
    3. 「알 수 없는 출처」 허용 필요 시 설정에서 활성화
    4. ' + - '
    5. 앱은 서버(exdev.co.kr)에 자동 연결됩니다
    6. ' + - '
    7. 새 버전은 앱 시작·설정 → 업데이트 확인
    '; + '
  3. 패키지: com.goldenchart.app (다른 GoldenChart 앱과 구분)
  4. ' + + '
  5. 「Unable to load script」 오류 시 예전 앱을 삭제 후 재설치
  6. ' + + '
  7. 앱은 서버(exdev.co.kr)에 자동 연결됩니다
'; QRCode.toCanvas(document.getElementById('qr'), url, { width: 200, margin: 1 }); } catch (e) { el.innerHTML = '

앱 정보를 불러오지 못했습니다.

'; diff --git a/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx b/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx index 7985019..6c16abd 100644 --- a/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx +++ b/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx @@ -37,9 +37,27 @@ const FloatingWidgetWindow: React.FC = ({ nativeShell = false, }) => { const [pickSlotId, setPickSlotId] = useState(null); + const bodyWrapRef = useRef(null); const bodyRef = useRef(null); const [bodySize, setBodySize] = useState({ width: 0, height: 0 }); + const resolveBodySize = useCallback(() => { + const wrap = bodyWrapRef.current; + const body = bodyRef.current; + const width = wrap?.clientWidth ?? body?.clientWidth ?? bodySize.width; + const height = wrap?.clientHeight ?? body?.clientHeight ?? bodySize.height; + if (width > 0 && height > 0) { + return { width, height }; + } + if (nativeShell) { + return { + width: width > 0 ? width : instance.width, + height: height > 0 ? height : instance.height, + }; + } + return { width, height }; + }, [bodySize.height, bodySize.width, instance.height, instance.width, nativeShell]); + const rowFr = instance.rowFr?.length === instance.rows ? instance.rowFr : defaultGridFr(instance.rows); @@ -92,15 +110,17 @@ const FloatingWidgetWindow: React.FC = ({ onUpdateGridFr(next, colFrRef.current); }, [onUpdateGridFr]); + const notifyLayoutSync = useCallback(() => { + window.dispatchEvent(new CustomEvent('gc-widget-layout-sync')); + }, []); + const { onColSplitPointerDown, onRowSplitPointerDown } = useFloatingWidgetGridSplit({ onColFrChange: handleColFrChange, onRowFrChange: handleRowFrChange, getColFr: () => colFrRef.current, getRowFr: () => rowFrRef.current, - getBodySize: () => ({ - width: bodyRef.current?.clientWidth ?? bodySize.width, - height: bodyRef.current?.clientHeight ?? bodySize.height, - }), + getBodySize: resolveBodySize, + onSplitEnd: notifyLayoutSync, }); const updateSlot = useCallback((slotId: string, updater: (s: WidgetSlot) => WidgetSlot) => { @@ -119,24 +139,30 @@ const FloatingWidgetWindow: React.FC = ({ /* 창·그리드 크기 변경 시 내부 차트·위젯 레이아웃 재동기화 */ useEffect(() => { - const el = bodyRef.current; + const el = bodyWrapRef.current ?? bodyRef.current; if (!el) return undefined; let timer: ReturnType | null = null; const scheduleLayoutSync = () => { - setBodySize({ width: el.clientWidth, height: el.clientHeight }); + const wrap = bodyWrapRef.current; + const body = bodyRef.current; + setBodySize({ + width: wrap?.clientWidth ?? body?.clientWidth ?? 0, + height: wrap?.clientHeight ?? body?.clientHeight ?? 0, + }); if (timer) clearTimeout(timer); - timer = setTimeout(() => { - window.dispatchEvent(new CustomEvent('gc-widget-layout-sync')); - }, 80); + timer = setTimeout(notifyLayoutSync, 80); }; scheduleLayoutSync(); const ro = new ResizeObserver(() => scheduleLayoutSync()); ro.observe(el); + if (bodyRef.current && bodyRef.current !== el) { + ro.observe(bodyRef.current); + } return () => { if (timer) clearTimeout(timer); ro.disconnect(); }; - }, [instance.rows, instance.cols]); + }, [instance.rows, instance.cols, notifyLayoutSync]); const colSplitPositions = useMemo( () => gridSplitPositions(colFr, bodySize.width, 1), @@ -193,7 +219,7 @@ const FloatingWidgetWindow: React.FC = ({ )} -
+
= ({ key={`col-${index}`} className="fw-grid-splitter fw-grid-splitter--v" style={{ left }} + data-no-chart-pan="true" role="separator" aria-orientation="vertical" aria-label={`열 ${index + 1}·${index + 2} 경계 조절`} @@ -232,6 +259,7 @@ const FloatingWidgetWindow: React.FC = ({ key={`row-${index}`} className="fw-grid-splitter fw-grid-splitter--h" style={{ top }} + data-no-chart-pan="true" role="separator" aria-orientation="horizontal" aria-label={`행 ${index + 1}·${index + 2} 경계 조절`} diff --git a/frontend/src/hooks/useFloatingWidgetGridSplit.ts b/frontend/src/hooks/useFloatingWidgetGridSplit.ts index 730dd26..4d51e57 100644 --- a/frontend/src/hooks/useFloatingWidgetGridSplit.ts +++ b/frontend/src/hooks/useFloatingWidgetGridSplit.ts @@ -5,7 +5,7 @@ import { FLOATING_WIDGET_CELL_MIN_W, FLOATING_WIDGET_GRID_GAP, } from '../types/floatingWidget'; -import { bindWindowDrag, isPrimaryPointerButton } from '../utils/pointerDrag'; +import { isPrimaryPointerButton } from '../utils/pointerDrag'; type SplitAxis = 'col' | 'row'; @@ -15,6 +15,7 @@ interface Options { getColFr: () => number[]; getRowFr: () => number[]; getBodySize: () => { width: number; height: number }; + onSplitEnd?: () => void; } export function useFloatingWidgetGridSplit({ @@ -23,18 +24,12 @@ export function useFloatingWidgetGridSplit({ getColFr, getRowFr, getBodySize, + onSplitEnd, }: Options) { - const unbindDragRef = useRef<(() => void) | null>(null); - - const endDrag = useCallback(() => { - unbindDragRef.current?.(); - unbindDragRef.current = null; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }, []); + const activeDragRef = useRef<(() => void) | null>(null); useEffect(() => () => { - unbindDragRef.current?.(); + activeDragRef.current?.(); document.body.style.cursor = ''; document.body.style.userSelect = ''; }, []); @@ -60,43 +55,67 @@ export function useFloatingWidgetGridSplit({ const startY = e.clientY; const startColFr = [...getColFr()]; const startRowFr = [...getRowFr()]; - const startSize = getBodySize(); document.body.style.cursor = axis === 'col' ? 'col-resize' : 'row-resize'; document.body.style.userSelect = 'none'; - unbindDragRef.current?.(); - unbindDragRef.current = bindWindowDrag( - (xy) => { - if (axis === 'col') { - const delta = xy.x - startX; - onColFrChange(adjustAdjacentGridFr( - startColFr, - index, - delta, - startSize.width, - FLOATING_WIDGET_GRID_GAP, - FLOATING_WIDGET_CELL_MIN_W, - )); - return; - } - const delta = xy.y - startY; - onRowFrChange(adjustAdjacentGridFr( - startRowFr, + activeDragRef.current?.(); + + const onMove = (ev: PointerEvent) => { + const size = getBodySize(); + if (axis === 'col') { + if (size.width <= 0) return; + const delta = ev.clientX - startX; + onColFrChange(adjustAdjacentGridFr( + startColFr, index, delta, - startSize.height, + size.width, FLOATING_WIDGET_GRID_GAP, - FLOATING_WIDGET_CELL_MIN_H, + FLOATING_WIDGET_CELL_MIN_W, )); - }, - () => { - handle.classList.remove('fw-grid-splitter--active'); - endDrag(); - }, - { preventTouchScroll: true }, - ); - }, [endDrag, getBodySize, getColFr, getRowFr, onColFrChange, onRowFrChange]); + return; + } + if (size.height <= 0) return; + const delta = ev.clientY - startY; + onRowFrChange(adjustAdjacentGridFr( + startRowFr, + index, + delta, + size.height, + FLOATING_WIDGET_GRID_GAP, + FLOATING_WIDGET_CELL_MIN_H, + )); + }; + + const onUp = (ev: PointerEvent) => { + handle.classList.remove('fw-grid-splitter--active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + try { + if (handle.hasPointerCapture(ev.pointerId)) { + handle.releasePointerCapture(ev.pointerId); + } + } catch { + /* ignore */ + } + document.removeEventListener('pointermove', onMove, true); + document.removeEventListener('pointerup', onUp, true); + document.removeEventListener('pointercancel', onUp, true); + activeDragRef.current = null; + onSplitEnd?.(); + }; + + document.addEventListener('pointermove', onMove, { capture: true }); + document.addEventListener('pointerup', onUp, { capture: true }); + document.addEventListener('pointercancel', onUp, { capture: true }); + + activeDragRef.current = () => { + document.removeEventListener('pointermove', onMove, true); + document.removeEventListener('pointerup', onUp, true); + document.removeEventListener('pointercancel', onUp, true); + }; + }, [getBodySize, getColFr, getRowFr, onColFrChange, onRowFrChange, onSplitEnd]); const onColSplitPointerDown = useCallback( (index: number) => (e: React.PointerEvent) => startSplit(e, 'col', index), diff --git a/frontend/src/styles/floatingWidget.css b/frontend/src/styles/floatingWidget.css index e612839..768b435 100644 --- a/frontend/src/styles/floatingWidget.css +++ b/frontend/src/styles/floatingWidget.css @@ -648,8 +648,22 @@ html.fw-native-widget #root { .fw-window--native .fw-window-body-wrap { flex: 1; min-height: 0; + min-width: 0; + position: relative; } .fw-window--native .fw-window-body { + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; height: 100%; } + +.fw-window--native .fw-grid-split-layer { + z-index: 1000; +} + +.fw-window--native .fw-grid-splitter { + z-index: 1001; +} diff --git a/package-lock.json b/package-lock.json index 8c6da94..a20b703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "dependencies": { "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", + "@capacitor/browser": "^7.0.5", "@capacitor/core": "^7.0.0", "@capacitor/haptics": "^7.0.0", "@capacitor/ios": "^7.0.0", @@ -421,6 +422,15 @@ "@capacitor/core": ">=7.0.0" } }, + "node_modules/@capacitor/browser": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-7.0.5.tgz", + "integrity": "sha512-YF/kC1m1e6c81X/agtAnYItVlqiQ/nXLG+Q3QXNDuiMoRynqa3Guu8ZaV9kWvqQcHoE2w18xz7WGpPOuh+R1fA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor/cli": { "version": "7.6.5", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.6.5.tgz", diff --git a/scripts/build-mobile-apk.sh b/scripts/build-mobile-apk.sh index 3368040..00f53f3 100755 --- a/scripts/build-mobile-apk.sh +++ b/scripts/build-mobile-apk.sh @@ -43,6 +43,13 @@ sign_release_apk() { --ks-pass pass:goldenchart --key-pass pass:goldenchart "$signed" mkdir -p "$(dirname "$LOCAL_APK")" cp "$signed" "$LOCAL_APK" + + VERSION_NAME="$(grep -E 'versionName[[:space:]]+"' "$ROOT/app/android/app/build.gradle" | head -1 | sed -E 's/.*versionName[[:space:]]+"([^"]+)".*/\1/' || true)" + if [[ -n "$VERSION_NAME" ]]; then + printf '%s\n' "$VERSION_NAME" > "${LOCAL_APK%.apk}.version" + log "버전 sidecar: ${LOCAL_APK%.apk}.version → $VERSION_NAME" + fi + log "서명 완료: $LOCAL_APK" }