feat: Phase 1 mobile tablet — Jenkins APK, PC tab, in-app update
Capacitor 확장으로 git push 시 Mobile Jenkins 빌드, PC 탭 Android/iPad 다운로드, 앱 내 APK 업데이트, iOS TestFlight API 및 Capacitor ios 프로젝트를 추가한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-1
@@ -8,6 +8,7 @@ import { TradeNotificationProvider, useTradeNotification } from './contexts/Trad
|
||||
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
|
||||
import TabBar, { type TabId } from './components/TabBar';
|
||||
import LiveSignalBridge from './components/LiveSignalBridge';
|
||||
import AppUpdateGate from './components/AppUpdateGate';
|
||||
import LoginScreen from './screens/LoginScreen';
|
||||
import '@frontend/styles/splashScreen.css';
|
||||
import './theme/global.css';
|
||||
@@ -116,7 +117,9 @@ function AppRoot() {
|
||||
popupEnabled={false}
|
||||
settingsSessionKey={sessionKey}
|
||||
>
|
||||
<MainApp />
|
||||
<AppUpdateGate>
|
||||
<MainApp />
|
||||
</AppUpdateGate>
|
||||
</TradeNotificationProvider>
|
||||
</NavigationProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { promptAppUpdateIfNeeded } from '../services/appUpdate';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/** Android 네이티브 — 시작 시 서버 APK 버전 확인 (무음 스킵, 이후 설정에서 수동 확인) */
|
||||
export default function AppUpdateGate({ children }: Props) {
|
||||
const [ready, setReady] = useState(() =>
|
||||
!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const { checkForAppUpdate, openApkDownload } = await import('../services/appUpdate');
|
||||
const check = await checkForAppUpdate();
|
||||
if (!cancelled && check.updateAvailable && check.remoteVersion) {
|
||||
const dismissed = sessionStorage.getItem(`gc_skip_update_${check.remoteVersion}`);
|
||||
if (!dismissed) {
|
||||
const ok = window.confirm(
|
||||
`GoldenChart v${check.remoteVersion} 업데이트가 있습니다.\n`
|
||||
+ `(현재 v${check.currentVersion})\n\n지금 APK를 다운로드하시겠습니까?`,
|
||||
);
|
||||
if (ok) {
|
||||
await openApkDownload(check.release);
|
||||
} else {
|
||||
sessionStorage.setItem(`gc_skip_update_${check.remoteVersion}`, '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 네트워크 오류 — 앱 진입 허용 */
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [ready]);
|
||||
|
||||
if (!ready) {
|
||||
return <div className="loading-center">업데이트 확인…</div>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { App } from '@capacitor/app';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import {
|
||||
resetPaperAccount,
|
||||
sendFcmTest,
|
||||
@@ -20,11 +22,21 @@ export default function SettingsScreen() {
|
||||
const [fcmAvailable, setFcmAvailable] = useState(false);
|
||||
const [pushPerm, setPushPerm] = useState<string>('—');
|
||||
const [fcmBusy, setFcmBusy] = useState(false);
|
||||
const [updateBusy, setUpdateBusy] = useState(false);
|
||||
const [appVersion, setAppVersion] = useState('—');
|
||||
|
||||
useEffect(() => {
|
||||
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
void App.getInfo().then(i => setAppVersion(i.version ?? '—'));
|
||||
} else {
|
||||
setAppVersion('web');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
|
||||
|
||||
const onLogout = () => {
|
||||
@@ -205,6 +217,30 @@ export default function SettingsScreen() {
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="앱">
|
||||
<SettingRow label="앱 버전">
|
||||
<span className="text-muted">{appVersion}</span>
|
||||
</SettingRow>
|
||||
{Capacitor.getPlatform() === 'android' && (
|
||||
<SettingRow label="업데이트" description="서버 APK 버전 확인">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ padding: '8px 12px', minHeight: 36 }}
|
||||
disabled={updateBusy}
|
||||
onClick={() => {
|
||||
setUpdateBusy(true);
|
||||
void import('../../services/appUpdate')
|
||||
.then(m => m.checkAndPromptAppUpdate())
|
||||
.finally(() => setUpdateBusy(false));
|
||||
}}
|
||||
>
|
||||
업데이트 확인
|
||||
</button>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="네트워크">
|
||||
<SettingRow label="API URL">
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Android 앱 내 업데이트 — /api/mobile-app/info 버전 비교 후 APK 다운로드
|
||||
*/
|
||||
import { App } from '@capacitor/app';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import {
|
||||
isRemoteVersionNewer,
|
||||
loadMobileAppReleaseInfo,
|
||||
type MobileAppReleaseInfoDto,
|
||||
} from '../lib/shared';
|
||||
|
||||
export interface AppUpdateCheckResult {
|
||||
updateAvailable: boolean;
|
||||
currentVersion: string;
|
||||
remoteVersion?: string;
|
||||
release?: MobileAppReleaseInfoDto | null;
|
||||
}
|
||||
|
||||
export async function getCurrentAppVersion(): Promise<string> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return import.meta.env.VITE_APP_VERSION ?? '0.0.0';
|
||||
}
|
||||
try {
|
||||
const info = await App.getInfo();
|
||||
return info.version ?? '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForAppUpdate(): Promise<AppUpdateCheckResult> {
|
||||
const currentVersion = await getCurrentAppVersion();
|
||||
const release = await loadMobileAppReleaseInfo();
|
||||
const remoteVersion = release?.version?.trim();
|
||||
const updateAvailable = Boolean(
|
||||
release?.available
|
||||
&& remoteVersion
|
||||
&& isRemoteVersionNewer(currentVersion, remoteVersion),
|
||||
);
|
||||
return { updateAvailable, currentVersion, remoteVersion, release };
|
||||
}
|
||||
|
||||
export async function openApkDownload(release?: MobileAppReleaseInfoDto | null): Promise<boolean> {
|
||||
const info = release ?? await loadMobileAppReleaseInfo();
|
||||
const url = info?.downloadUrl?.trim();
|
||||
if (!url) return false;
|
||||
|
||||
window.location.href = url;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function promptAppUpdateIfNeeded(silent = false): Promise<boolean> {
|
||||
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const check = await checkForAppUpdate();
|
||||
if (!check.updateAvailable || !check.remoteVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (silent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ok = window.confirm(
|
||||
`새 버전(v${check.remoteVersion})이 있습니다.\n`
|
||||
+ `현재: v${check.currentVersion}\n\n`
|
||||
+ 'APK를 다운로드하여 설치하시겠습니까?',
|
||||
);
|
||||
if (!ok) return false;
|
||||
|
||||
await openApkDownload(check.release);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 설정 화면 등 — 수동 업데이트 확인 */
|
||||
export async function checkAndPromptAppUpdate(): Promise<AppUpdateCheckResult> {
|
||||
const result = await checkForAppUpdate();
|
||||
if (!result.updateAvailable) {
|
||||
window.alert(`최신 버전입니다 (v${result.currentVersion}).`);
|
||||
return result;
|
||||
}
|
||||
const ok = window.confirm(
|
||||
`새 버전 v${result.remoteVersion} 사용 가능.\nAPK를 다운로드하시겠습니까?`,
|
||||
);
|
||||
if (ok) {
|
||||
await openApkDownload(result.release);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user