feat: Gateway 웹 알림 설정과 PWA 구독 화면을 추가한다

계정 화면에서 profile별 9개 알림을 기본 꺼짐으로 설정하고 Android, iPhone 홈 화면 앱, Windows 브라우저용 service worker 구독 흐름을 제공한다. 전송 비활성 상태와 모바일 Chromium 경계를 E2E로 검증한다.
This commit is contained in:
2026-08-23 13:16:14 +00:00
parent 7bc3213b03
commit 4eee4d2a61
9 changed files with 650 additions and 2 deletions
+8
View File
@@ -13,3 +13,11 @@ app.use(createPinia());
app.use(router);
app.mount('#app');
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`, {
scope: import.meta.env.BASE_URL,
});
});
}
@@ -8,10 +8,19 @@ import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
import { sealPassword } from '../utils/passwordEnvelope';
import type { WebPushEventType } from '@sammo-ts/common';
type Account = Awaited<ReturnType<typeof trpc.account.get.query>>;
type IconSyncProfile = Awaited<ReturnType<typeof trpc.account.changeIcon.mutate>>['profiles'][number];
type IconSyncState = 'idle' | 'pending' | 'success' | 'error';
type NotificationState = Awaited<ReturnType<typeof trpc.account.notifications.get.query>>;
type LocalNotificationPreference = {
profileName: string;
eventType: WebPushEventType;
enabled: boolean;
targetYear: number | null;
targetMonth: number | null;
};
type IconSyncRow = IconSyncProfile & {
selected: boolean;
state: IconSyncState;
@@ -40,12 +49,204 @@ const iconServerStaticFeedback = ref(false);
const iconServerMessage = ref('');
const iconServerRows = ref<IconSyncRow[]>([]);
const iconServerDialog = ref<HTMLElement | null>(null);
const notificationState = ref<NotificationState | null>(null);
const notificationPreferences = ref<LocalNotificationPreference[]>([]);
const selectedNotificationProfile = ref('');
const notificationBusy = ref(false);
const notificationPermission = ref<NotificationPermission | 'unsupported'>('default');
const currentPushSubscription = ref<PushSubscription | null>(null);
let iconServerReturnFocus: HTMLElement | null = null;
let previousBodyOverflow = '';
let iconServerStaticTimer: ReturnType<typeof setTimeout> | null = null;
const notificationLabels: Record<WebPushEventType, string> = {
TROOP_ANNIHILATED: '내 병력 전멸',
PRIVATE_MESSAGE_RECEIVED: '개인 메시지 수신',
AUTONOMOUS_ACTION_ENDED: '자율행동 종료',
RESERVED_TURNS_ENDED: '예턴 종료',
PROFILE_PREOPENED: '서버 가오픈',
PROFILE_OPEN_SCHEDULED: '서버 오픈 예약',
PROFILE_OPENED: '서버 오픈',
NATION_DESTROYED: '내 국가 멸망',
TARGET_DATE_REACHED: '특정 연월 도달',
};
const supportsWebPush = computed(
() => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
);
const isIos = computed(() => /iPad|iPhone|iPod/u.test(navigator.userAgent));
const isStandalone = computed(
() =>
window.matchMedia('(display-mode: standalone)').matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
);
const currentProfile = computed(() =>
notificationState.value?.profiles.find((profile) => profile.profileName === selectedNotificationProfile.value)
);
const notificationEventTypes = computed(
() => (notificationState.value?.eventTypes ?? []) as readonly WebPushEventType[]
);
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
const ensureNotificationPreference = (eventType: WebPushEventType): LocalNotificationPreference => {
const profileName = selectedNotificationProfile.value;
let preference = notificationPreferences.value.find(
(candidate) => candidate.profileName === profileName && candidate.eventType === eventType
);
if (!preference) {
preference = {
profileName,
eventType,
enabled: false,
targetYear: null,
targetMonth: null,
};
notificationPreferences.value.push(preference);
}
return preference;
};
const loadNotificationSettings = async (): Promise<void> => {
const token = sessionToken();
if (!token) return;
let endpoint: string | undefined;
if (supportsWebPush.value) {
notificationPermission.value = Notification.permission;
const registration = await navigator.serviceWorker.getRegistration(import.meta.env.BASE_URL);
currentPushSubscription.value = (await registration?.pushManager.getSubscription()) ?? null;
endpoint = currentPushSubscription.value?.endpoint;
} else {
notificationPermission.value = 'unsupported';
}
const state = await trpc.account.notifications.get.query({
sessionToken: token,
...(endpoint ? { currentEndpoint: endpoint } : {}),
});
notificationState.value = state;
notificationPreferences.value = state.preferences.map((preference) => ({
profileName: preference.profileName,
eventType: preference.eventType as WebPushEventType,
enabled: preference.enabled,
targetYear: preference.targetYear,
targetMonth: preference.targetMonth,
}));
if (
!selectedNotificationProfile.value ||
!state.profiles.some((profile) => profile.profileName === selectedNotificationProfile.value)
) {
selectedNotificationProfile.value = state.profiles[0]?.profileName ?? '';
}
};
const base64UrlToUint8Array = (value: string): Uint8Array<ArrayBuffer> => {
const padding = '='.repeat((4 - (value.length % 4)) % 4);
const raw = window.atob((value + padding).replace(/-/gu, '+').replace(/_/gu, '/'));
const result = new Uint8Array(new ArrayBuffer(raw.length));
for (let index = 0; index < raw.length; index += 1) result[index] = raw.charCodeAt(index);
return result;
};
const subscribeCurrentDevice = async (): Promise<void> => {
if (notificationBusy.value || !notificationState.value?.capability.enabled) return;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
const publicKey = notificationState.value.capability.publicKey;
if (!token || !publicKey) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.');
if (!supportsWebPush.value) throw new Error('이 브라우저는 Web Push를 지원하지 않습니다.');
if (isIos.value && !isStandalone.value) {
throw new Error('iPhone에서는 Safari의 공유 메뉴에서 홈 화면에 추가한 뒤 그 아이콘으로 열어 주세요.');
}
const permission = await Notification.requestPermission();
notificationPermission.value = permission;
if (permission !== 'granted') throw new Error('브라우저 알림 권한이 허용되지 않았습니다.');
const registration = await navigator.serviceWorker.ready;
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToUint8Array(publicKey),
}));
const json = subscription.toJSON();
if (!json.endpoint || !json.keys?.p256dh || !json.keys.auth) {
throw new Error('브라우저가 올바른 Push 구독 정보를 반환하지 않았습니다.');
}
await trpc.account.notifications.subscribe.mutate({
sessionToken: token,
subscription: {
endpoint: json.endpoint,
expirationTime: subscription.expirationTime,
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
},
});
currentPushSubscription.value = subscription;
notificationState.value = {
...notificationState.value,
currentDeviceSubscribed: true,
subscriptionCount: notificationState.value.subscriptionCount + 1,
};
successMessage.value = '이 기기의 웹 알림을 등록했습니다.';
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '이 기기의 웹 알림을 등록하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const unsubscribeCurrentDevice = async (): Promise<void> => {
if (notificationBusy.value || !currentPushSubscription.value) return;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
const endpoint = currentPushSubscription.value.endpoint;
await trpc.account.notifications.unsubscribe.mutate({ sessionToken: token, endpoint });
await currentPushSubscription.value.unsubscribe();
currentPushSubscription.value = null;
if (notificationState.value) {
notificationState.value = {
...notificationState.value,
currentDeviceSubscribed: false,
subscriptionCount: Math.max(0, notificationState.value.subscriptionCount - 1),
};
}
successMessage.value = '이 기기의 웹 알림을 해제했습니다.';
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '이 기기의 웹 알림을 해제하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const saveNotificationPreference = async (eventType: WebPushEventType, revertToggle = false): Promise<void> => {
if (notificationBusy.value || !selectedNotificationProfile.value) return;
const preference = ensureNotificationPreference(eventType);
const previousEnabled = revertToggle ? !preference.enabled : preference.enabled;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
await trpc.account.notifications.setPreference.mutate({
sessionToken: token,
profileName: selectedNotificationProfile.value,
eventType,
enabled: preference.enabled,
targetYear: preference.targetYear,
targetMonth: preference.targetMonth,
});
successMessage.value = `${notificationLabels[eventType]} 알림 설정을 저장했습니다.`;
} catch (error) {
preference.enabled = previousEnabled;
errorMessage.value = error instanceof Error ? error.message : '알림 설정을 저장하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const gradeLabel = computed(() => {
if (!account.value) return '-';
if (account.value.roles.some((role) => role.includes('admin') || role === 'superuser')) return '관리자';
@@ -79,6 +280,7 @@ const loadAccount = async (): Promise<void> => {
}
try {
account.value = await trpc.account.get.query({ sessionToken: token });
await loadNotificationSettings();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '계정 정보를 불러오지 못했습니다.';
} finally {
@@ -590,6 +792,117 @@ onBeforeUnmount(() => {
</tr>
</tfoot>
</table>
<table v-if="notificationState" id="notification-table" class="legacy-bg0">
<thead>
<tr>
<th colspan="2" class="legacy-bg1"> 알림 설정</th>
</tr>
</thead>
<tbody>
<tr>
<th class="legacy-bg1 notification-label">전송 상태</th>
<td class="notification-copy">
<strong>{{
notificationState.capability.enabled ? '사용 가능' : '준비됨 · 운영 비활성'
}}</strong>
<p v-if="!notificationState.capability.enabled">
전송 기능은 아직 운영 설정에서 꺼져 있습니다. 개별 설정은 저장되지만 실제 알림은
발송되지 않습니다.
</p>
<p v-else>
권한: {{ notificationPermission }} · 등록 기기
{{ notificationState.subscriptionCount }}
</p>
<p v-if="isIos && !isStandalone">
iPhone은 Safari 공유 메뉴의 화면에 추가 , 설치된 아이콘으로 열어야 알림을
있습니다.
</p>
<button
v-if="currentPushSubscription"
class="skin-button"
type="button"
:disabled="notificationBusy"
@click="unsubscribeCurrentDevice"
>
기기 알림 해제
</button>
<button
v-else
class="skin-button"
type="button"
:disabled="
notificationBusy || !notificationState.capability.enabled || !supportsWebPush
"
@click="subscribeCurrentDevice"
>
기기 알림 켜기
</button>
</td>
</tr>
<tr>
<th class="legacy-bg1 notification-label">서버</th>
<td>
<select
v-model="selectedNotificationProfile"
class="skin-input notification-profile-select"
>
<option
v-for="profile in notificationState.profiles"
:key="profile.profileName"
:value="profile.profileName"
>
{{ profile.profile }} · {{ profile.currentScenario ?? profile.profileName }}
</option>
</select>
<span v-if="currentProfile" class="notification-profile-status">{{
currentProfile.status
}}</span>
</td>
</tr>
<tr v-for="eventType in notificationEventTypes" :key="eventType">
<th class="legacy-bg1 notification-label">{{ notificationLabels[eventType] }}</th>
<td class="notification-preference">
<label>
<input
v-model="ensureNotificationPreference(eventType).enabled"
type="checkbox"
:disabled="notificationBusy || !selectedNotificationProfile"
@change="saveNotificationPreference(eventType, true)"
/>
알림 받기
</label>
<span v-if="eventType === 'TARGET_DATE_REACHED'" class="target-date-fields">
<input
v-model.number="ensureNotificationPreference(eventType).targetYear"
class="skin-input target-year"
type="number"
min="0"
max="9999"
aria-label="목표 연도"
@change="
ensureNotificationPreference(eventType).enabled &&
saveNotificationPreference(eventType)
"
/>
<input
v-model.number="ensureNotificationPreference(eventType).targetMonth"
class="skin-input target-month"
type="number"
min="1"
max="12"
aria-label="목표 "
@change="
ensureNotificationPreference(eventType).enabled &&
saveNotificationPreference(eventType)
"
/>
</span>
</td>
</tr>
</tbody>
</table>
<p v-if="successMessage" class="feedback success" role="status">{{ successMessage }}</p>
<p v-if="errorMessage" class="feedback error" role="alert">{{ errorMessage }}</p>
</div>
@@ -717,6 +1030,65 @@ onBeforeUnmount(() => {
text-align: center;
}
#notification-table {
width: 100%;
margin-top: 16px;
border: 1px solid gray;
border-spacing: 0;
table-layout: fixed;
}
#notification-table th,
#notification-table td {
border: 1px solid;
border-color: gray #000 #000 gray;
padding: 6px;
}
.notification-label {
width: 150px;
text-align: center;
}
.notification-copy {
text-align: left;
}
.notification-copy p {
margin: 4px 0;
color: #ddd;
line-height: 1.4;
}
.notification-profile-select {
width: min(310px, calc(100% - 80px));
min-height: 28px;
}
.notification-profile-status {
margin-left: 8px;
color: #bbb;
}
.notification-preference {
text-align: left;
}
.target-date-fields {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 18px;
}
.target-year {
width: 72px;
}
.target-month {
width: 48px;
}
.legacy-bg0 {
background-color: #302016;
background-image: var(--sammo-texture-walnut, url('https://sam-image.hided.net/game/back_walnut.jpg'));
@@ -1138,6 +1510,20 @@ onBeforeUnmount(() => {
margin-left: 0;
}
#notification-table {
width: 100vw;
max-width: 100vw;
}
.notification-label {
width: 118px;
}
.target-date-fields {
flex-wrap: wrap;
margin: 6px 0 0;
}
.icon-server-backdrop {
padding-right: 8px;
padding-left: 8px;