Simplify connection flow, mobile transfer UI, and header actions

This commit is contained in:
2026-10-12 18:14:54 +09:00
parent 5f431ee153
commit 4f0231da3d
22 changed files with 820 additions and 131 deletions

View File

@@ -20,6 +20,8 @@ GUEST_SESSION_RETENTION_DAYS=30
LOCAL_REQUEST_TIMEOUT_SECONDS=60 LOCAL_REQUEST_TIMEOUT_SECONDS=60
ACCEPTED_UPLOAD_START_HOURS=24 ACCEPTED_UPLOAD_START_HOURS=24
SPACE_RETENTION_MINUTES=10 SPACE_RETENTION_MINUTES=10
# Invite codes can be 4 or 6 characters.
SPACE_INVITE_CODE_LENGTH=4
SPACE_MAX_DEVICES=10 SPACE_MAX_DEVICES=10
OFFLINE_GRACE_MS=3000 OFFLINE_GRACE_MS=3000
MAX_URL_PAYLOAD_SIZE_KB=1024 MAX_URL_PAYLOAD_SIZE_KB=1024

View File

@@ -93,7 +93,7 @@ QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10
## NAS 업데이트·PWA·개인 용량 ## NAS 업데이트·PWA·개인 용량
이번 NAS 업데이트는 [deploy.md](deploy.md)의 7단계부터 진행합니다. 운영 자동 발견은 실제 Tunnel → NPM → hop. 경로를 진단해 인접 프록시만 신뢰하며, Cloudflare의 원본 IP로 같은 네트워크 기기를 구분합니다. 공개 주소는 HTTPS를 사용합니다. 모바일 상단 설치 버튼으로 PWA 설치 또는 iPhone 홈 화면 추가를 안내합니다. 파일·계정 기록·API 응답은 오프라인 캐시에 넣지 않습니다. 이번 NAS 업데이트는 [deploy.md](deploy.md)의 7단계부터 진행합니다. 운영 자동 발견은 실제 Tunnel → NPM → hop. 경로를 진단해 인접 프록시만 신뢰하며, Cloudflare의 원본 IP로 같은 네트워크 기기를 구분합니다. 공개 주소는 HTTPS를 사용합니다. 상단 더보기의 앱 설치 메뉴로 PWA 설치 또는 iPhone 홈 화면 추가를 안내합니다. 파일·계정 기록·API 응답은 오프라인 캐시에 넣지 않습니다.
로그인 계정의 임시 저장과 받은 파일을 합쳐 개인별 2GiB로 제한합니다. 같은 원본은 한 계정에서 한 번 계산하고 다른 계정의 한도와 분리합니다. 한도가 차면 파일 수신·다운로드는 계속하고 받은 항목에 저장하지 않았다는 안내를 표시합니다. 익명 업로드는 기기 세션당 2GiB, 서버 전체 20GiB이며 화면에는 내역을 표시하지 않습니다. 로그인 계정의 임시 저장과 받은 파일을 합쳐 개인별 2GiB로 제한합니다. 같은 원본은 한 계정에서 한 번 계산하고 다른 계정의 한도와 분리합니다. 한도가 차면 파일 수신·다운로드는 계속하고 받은 항목에 저장하지 않았다는 안내를 표시합니다. 익명 업로드는 기기 세션당 2GiB, 서버 전체 20GiB이며 화면에는 내역을 표시하지 않습니다.

View File

@@ -331,6 +331,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
device: service.device(g.id), device: service.device(g.id),
devices: service.peers(g), devices: service.peers(g),
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
spaceInviteCodeLength: cfg.spaceInviteCodeLength,
locationDiscovery: { locationDiscovery: {
enabled: cfg.locationDiscoveryEnabled, enabled: cfg.locationDiscoveryEnabled,
radiusMeters: cfg.locationRadiusMeters, radiusMeters: cfg.locationRadiusMeters,

View File

@@ -18,6 +18,7 @@ export type Config = {
requestSeconds: number; requestSeconds: number;
uploadStartHours: number; uploadStartHours: number;
spaceMinutes: number; spaceMinutes: number;
spaceInviteCodeLength: number;
spaceMax: number; spaceMax: number;
offlineGraceMs: number; offlineGraceMs: number;
maxUrlBytes: number; maxUrlBytes: number;
@@ -67,6 +68,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters'); throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters');
if (num('LOCATION_DISCOVERY_RADIUS_METERS', 100) > 1000) if (num('LOCATION_DISCOVERY_RADIUS_METERS', 100) > 1000)
throw Error('LOCATION_DISCOVERY_RADIUS_METERS must not exceed 1000'); throw Error('LOCATION_DISCOVERY_RADIUS_METERS must not exceed 1000');
const spaceInviteCodeLength = num('SPACE_INVITE_CODE_LENGTH', 4);
if (![4, 6].includes(spaceInviteCodeLength))
throw Error('SPACE_INVITE_CODE_LENGTH must be 4 or 6');
return { return {
production, production,
appUrl, appUrl,
@@ -92,6 +96,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60), requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60),
uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24), uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24),
spaceMinutes: num('SPACE_RETENTION_MINUTES', 10), spaceMinutes: num('SPACE_RETENTION_MINUTES', 10),
spaceInviteCodeLength,
spaceMax: num('SPACE_MAX_DEVICES', 10), spaceMax: num('SPACE_MAX_DEVICES', 10),
offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0), offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0),
maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024, maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024,

View File

@@ -0,0 +1,10 @@
import { randomInt } from 'node:crypto';
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export function newInviteCode(length, isTaken, random = randomInt) {
for (let attempt = 0; attempt < 32; attempt++) {
const candidate = Array.from({ length }, () => alphabet[random(alphabet.length)]).join('');
if (!isTaken(candidate)) return candidate;
}
return null;
}

View File

@@ -5,7 +5,8 @@ import { Storage } from './storage.js';
import { ResumableUploads } from './resumable.js'; import { ResumableUploads } from './resumable.js';
import { NearbyLocations } from './nearby.js'; import { NearbyLocations } from './nearby.js';
import { Vault } from './vault.js'; import { Vault } from './vault.js';
import { randomInt, randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { newInviteCode } from './inviteCode.js';
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm'; import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
import type { DB } from './db/index.js'; import type { DB } from './db/index.js';
import * as s from './db/schema.js'; import * as s from './db/schema.js';
@@ -380,8 +381,18 @@ export class Service {
const now = Date.now(), const now = Date.now(),
id = randomUUID(), id = randomUUID(),
token = randomToken(); token = randomToken();
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // The unique DB constraint includes historical spaces; never reuse their codes.
const code = Array.from({ length: 8 }, () => alphabet[randomInt(alphabet.length)]).join(''); const code =
newInviteCode(this.cfg.spaceInviteCodeLength, (candidate: string) =>
Boolean(
this.db
.select({ id: s.spaces.id })
.from(s.spaces)
.where(eq(s.spaces.codeHash, hmac(this.cfg.discoverySecret, candidate)))
.get(),
),
) ??
fail(503, 'SPACE_CODE_BUSY', '초대 코드를 만들지 못했습니다. 잠시 후 다시 시도해 주세요.');
this.db.transaction(() => { this.db.transaction(() => {
this.db this.db
.insert(s.spaces) .insert(s.spaces)

View File

@@ -0,0 +1,64 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import { newInviteCode } from '../src/inviteCode.js';
import { loadConfig } from '../src/config.js';
import { hmac } from '../src/security.js';
import * as s from '../src/db/schema.js';
test('짧은 초대 코드는 충돌 시 다시 생성하고 반복 충돌은 제한한다', () => {
let calls = 0;
const code = newInviteCode(
4,
(value) => value === 'AAAA',
() => (calls++ < 4 ? 0 : 1),
);
assert.equal(code, 'BBBB');
let attempts = 0;
assert.equal(
newInviteCode(
4,
() => {
attempts++;
return true;
},
() => 0,
),
null,
);
assert.equal(attempts, 32);
assert.equal(loadConfig({ SPACE_INVITE_CODE_LENGTH: '6' }).spaceInviteCodeLength, 6);
assert.throws(() => loadConfig({ SPACE_INVITE_CODE_LENGTH: '5' }), /SPACE_INVITE_CODE_LENGTH/);
});
test('새 초대 4자리와 6자리 설정, 기존 8자리 초대 모두 참여 가능하다', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const room = (await f.call(a, '/spaces', 'POST', {})).json();
assert.match(room.code, /^[A-HJ-NP-Z2-9]{4}$/);
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { code: room.code.toLowerCase() })).statusCode,
200,
);
await f.call(a, `/spaces/${room.space.id}`, 'DELETE');
f.service.cfg.spaceInviteCodeLength = 6;
const six = (await f.call(a, '/spaces', 'POST', {})).json();
assert.match(six.code, /^[A-HJ-NP-Z2-9]{6}$/);
assert.equal((await f.call(a, '/device/snapshot')).json().spaceInviteCodeLength, 6);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: six.code })).statusCode, 200);
await f.call(a, `/spaces/${six.space.id}`, 'DELETE');
const legacy = (await f.call(a, '/spaces', 'POST', {})).json();
f.service.db
.update(s.spaces)
.set({ codeHash: hmac(f.service.cfg.discoverySecret, 'ABCDEFGH') })
.where(eq(s.spaces.id, legacy.space.id))
.run();
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' })).statusCode, 200);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDE' })).statusCode, 400);
} finally {
await f.close();
}
});

View File

@@ -3,6 +3,8 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import { import {
ArrowUpRight, ArrowUpRight,
ArrowRight, ArrowRight,
ArrowLeft,
LogIn,
Plus, Plus,
Monitor, Monitor,
Smartphone, Smartphone,
@@ -29,19 +31,23 @@ import {
Download, Download,
RotateCcw, RotateCcw,
EllipsisVertical, EllipsisVertical,
Ellipsis,
Info,
} from '@lucide/vue'; } from '@lucide/vue';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import AccountPanel from './AccountPanel.vue'; import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue'; import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue'; import InstallButton from './InstallButton.vue';
import { createNearbySearch } from './nearbySearch'; import { createNearbySearch } from './nearbySearch';
import { useVisibleViewport } from './viewport';
useVisibleViewport();
import { createDeliveryReceiver } from './deliveryReceiver'; import { createDeliveryReceiver } from './deliveryReceiver';
import { initialTheme, applyTheme as setTheme } from './theme'; import { initialTheme, applyTheme as setTheme } from './theme';
import { import {
device, device,
account, account,
connected, connected,
discoveryEnabled, spaceInviteCodeLength,
locationSettings, locationSettings,
publishLocation, publishLocation,
clearLocation, clearLocation,
@@ -74,12 +80,34 @@ const tab = ref('devices'),
nameDraft = ref(''), nameDraft = ref(''),
notice = ref(''), notice = ref(''),
modal = ref(null), modal = ref(null),
headerMore = ref(false),
connectView = ref('choose'),
joinCode = ref(''), joinCode = ref(''),
approvalRequired = ref(false), approvalRequired = ref(false),
invite = ref(null), invite = ref(null),
qr = ref(''), qr = ref(''),
now = ref(Date.now()); now = ref(Date.now());
const localOnlyQr = /^(localhost|127(?:\.\d+){3}|\[::1\])$/.test(location.hostname); const normalizedJoinCode = computed(() => joinCode.value.replace(/[\s-]/g, '').toUpperCase());
const validJoinCode = computed(() =>
/^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/.test(normalizedJoinCode.value),
);
const connectTitle = computed(() =>
connectView.value === 'join'
? '연결 공간 참여'
: connectView.value === 'choose'
? '다른 기기 연결'
: '연결 공간 만들기',
);
watch(modal, (value) => {
if (value === 'connect') {
connectView.value =
invite.value && invite.value.space.expiresAt > now.value ? 'invite' : 'choose';
joinCode.value = '';
}
});
function formatInviteCode(code) {
return code.match(new RegExp(`.{1,${code.length === 6 ? 3 : 4}}`, 'g')).join(' ');
}
const theme = ref(initialTheme()); const theme = ref(initialTheme());
const media = matchMedia('(prefers-color-scheme: dark)'); const media = matchMedia('(prefers-color-scheme: dark)');
function applyTheme() { function applyTheme() {
@@ -133,6 +161,49 @@ async function receiveAction(r, action) {
}); });
} }
const deviceMenu = ref(null); const deviceMenu = ref(null);
async function toggleHeaderMore() {
if (headerMore.value) {
closeHeaderMore(true);
return;
}
closeDeviceMenu();
headerMore.value = true;
await nextTick();
document.querySelector('#header-more-menu [role="menuitem"]')?.focus();
}
function closeHeaderMore(restore = false) {
headerMore.value = false;
if (restore) document.getElementById('header-more-toggle')?.focus();
}
function headerMenuKey(event) {
if (event.key === 'Escape') {
event.preventDefault();
closeHeaderMore(true);
return;
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const options = [...event.currentTarget.querySelectorAll('[role="menuitem"]')].filter(
(el) => el.getClientRects().length,
);
const current = options.indexOf(document.activeElement);
const index =
event.key === 'Home'
? 0
: event.key === 'End'
? options.length - 1
: (current + (event.key === 'ArrowUp' ? -1 : 1) + options.length) % options.length;
options[index]?.focus();
}
function openAbout() {
closeHeaderMore();
modal.value = 'about';
}
function changeTheme() {
toggleTheme();
closeHeaderMore(true);
}
function isHost(id) { function isHost(id) {
return Boolean(id && spaces.value.some((s) => s.creatorDeviceId === id)); return Boolean(id && spaces.value.some((s) => s.creatorDeviceId === id));
} }
@@ -192,8 +263,13 @@ function closeDeviceMenu(restore = false) {
} }
function outsideDeviceMenu(event) { function outsideDeviceMenu(event) {
if (!event.target.closest('[data-device-menu]')) closeDeviceMenu(); if (!event.target.closest('[data-device-menu]')) closeDeviceMenu();
if (!event.target.closest('[data-header-more]')) closeHeaderMore();
} }
function escapeDeviceMenu(event) { function escapeDeviceMenu(event) {
if (event.key === 'Escape' && headerMore.value) {
event.preventDefault();
closeHeaderMore(true);
}
if (event.key === 'Escape' && deviceMenu.value) { if (event.key === 'Escape' && deviceMenu.value) {
event.preventDefault(); event.preventDefault();
closeDeviceMenu(true); closeDeviceMenu(true);
@@ -436,6 +512,7 @@ async function openDeviceSettings() {
nameDraft.value = device.value?.name ?? ''; nameDraft.value = device.value?.name ?? '';
modal.value = 'device'; modal.value = 'device';
await nextTick(); await nextTick();
if (!matchMedia('(max-width: 639px)').matches)
document.querySelector('.device-settings-form input')?.focus(); document.querySelector('.device-settings-form input')?.focus();
} }
async function saveName() { async function saveName() {
@@ -480,12 +557,13 @@ async function createSpace() {
color: { dark: '#111111', light: '#ffffff' }, color: { dark: '#111111', light: '#ffffff' },
errorCorrectionLevel: 'M', errorCorrectionLevel: 'M',
}); });
connectView.value = 'invite';
await refresh(); await refresh();
}); });
} }
async function joinSpace() { async function joinSpace() {
await act(async () => { await act(async () => {
await api('/spaces/join', 'POST', { code: joinCode.value.replace(/[\s-]/g, '').toUpperCase() }); await api('/spaces/join', 'POST', { code: normalizedJoinCode.value });
joinCode.value = ''; joinCode.value = '';
await refresh(); await refresh();
modal.value = null; modal.value = null;
@@ -556,16 +634,50 @@ async function spaceAction(space, action, memberId) {
<span class="header-tagline">기기 사이를 가볍게.</span> <span class="header-tagline">기기 사이를 가볍게.</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<InstallButton /> <button class="login-button" @click="tab = 'vault'">
<button
class="icon-button theme-toggle"
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
@click="toggleTheme"
>
<Moon v-if="theme === 'light'" :size="19" /><Sun v-else :size="19" /></button
><button class="login-button" @click="tab = 'vault'">
{{ account ? '보관함' : '로그인' }} <ArrowUpRight :size="15" /> {{ account ? '보관함' : '로그인' }} <ArrowUpRight :size="15" />
</button> </button>
<div class="header-more-wrap" data-header-more>
<button
id="header-more-toggle"
class="icon-button header-more-toggle"
aria-label="더보기"
aria-haspopup="menu"
:aria-expanded="headerMore"
aria-controls="header-more-menu"
@click="toggleHeaderMore"
>
<Ellipsis :size="22" />
</button>
<!-- Keep install listeners mounted even when the menu is closed. -->
<div
v-show="headerMore"
id="header-more-menu"
class="header-more-menu"
role="menu"
aria-label="더보기"
@keydown="headerMenuKey"
>
<InstallButton
:menu-item="true"
@open="closeHeaderMore()"
@closed="closeHeaderMore(true)"
/>
<button
class="theme-toggle"
role="menuitem"
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
@click="changeTheme"
>
<Moon v-if="theme === 'light'" :size="18" /><Sun v-else :size="18" /><span>{{
theme === 'light' ? '다크 테마' : '라이트 테마'
}}</span>
</button>
<button role="menuitem" @click="openAbout">
<Info :size="18" /><span>서비스 정보</span>
</button>
</div>
</div>
</div> </div>
</header> </header>
<main> <main>
@@ -807,13 +919,13 @@ async function spaceAction(space, action, memberId) {
v-if="canInvite" v-if="canInvite"
key="qr-connect" key="qr-connect"
class="qr-connect-card" class="qr-connect-card"
:aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'" :aria-label="spaces.length ? '초대 보기' : '다른 기기 연결'"
@click="modal = 'connect'" @click="modal = 'connect'"
> >
<QrCode :size="32" /><strong>{{ <QrCode :size="32" /><strong>{{
spaces.length ? 'QR 초대 보기' : 'QR로 연결' spaces.length ? '초대 보기' : '다른 기기 연결'
}}</strong }}</strong
><span>다른 기기 연결하기</span> ><span>QR 또는 코드</span>
</button> </button>
</TransitionGroup> </TransitionGroup>
</section> </section>
@@ -925,58 +1037,108 @@ async function spaceAction(space, action, memberId) {
class="modal" class="modal"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
:aria-label="modal === 'connect' ? 'QR·코드 연결' : '설정'" :aria-label="
modal === 'connect' ? connectTitle : modal === 'about' ? '서비스 정보' : '설정'
"
> >
<header> <header>
<h2>{{ modal === 'connect' ? 'QR · 코드로 연결' : '내 기기 설정' }}</h2> <div class="connect-dialog-title">
<button
v-if="modal === 'connect' && ['create', 'join'].includes(connectView)"
class="icon-button"
:disabled="busy"
@click="connectView = 'choose'"
aria-label="연결 방법 선택으로 돌아가기"
>
<ArrowLeft :size="20" />
</button>
<h2>
{{
modal === 'connect'
? connectTitle
: modal === 'about'
? '서비스 정보'
: '내 기기 설정'
}}
</h2>
</div>
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기"> <button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
<X :size="20" /> <X :size="20" />
</button> </button>
</header> </header>
<template v-if="modal === 'connect'" <template v-if="modal === 'connect'">
><p class="modal-subtitle"> <template v-if="connectView === 'choose'">
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다. <p class="modal-subtitle">서로 다른 네트워크에서도 기기를 연결할 수 있어요.</p>
</p> <div class="connection-choices">
<div v-if="invite && invite.space.expiresAt > now" class="invite-display"> <button
<p class="discovery-status">기기 참여를 기다리고 있어요.</p> class="connection-choice"
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" /> aria-label="연결 공간 만들기"
<p v-if="localOnlyQr" class="local-qr-note"> @click="connectView = 'create'"
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의 >
다른 브라우저에서 아래 코드를 입력해 주세요. <QrCode :size="30" /><strong>연결 공간 만들기</strong
</p> ><span>QR이나 코드로 초대</span>
<div class="invite-code">{{ invite.code.slice(0, 4) }} {{ invite.code.slice(4) }}</div> </button>
<button class="text-button" @click="copyValue(invite.code)"> <button
<Copy :size="14" />코드 복사 class="connection-choice"
aria-label="연결 공간 참여"
@click="connectView = 'join'"
>
<LogIn :size="30" /><strong>연결 공간 참여</strong><span>초대 코드로 참여</span>
</button> </button>
<p>
{{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 · 최대
10대
</p>
</div> </div>
<div v-else-if="!spaces.length" class="create-space"> </template>
<form
v-else-if="connectView === 'create'"
class="create-space"
@submit.prevent="createSpace"
>
<p class="modal-subtitle">QR이나 초대 코드로 기기를 초대하세요.</p>
<label class="checkbox-label" <label class="checkbox-label"
><input type="checkbox" v-model="approvalRequired" />참여할 때 내 승인 받기</label ><input type="checkbox" v-model="approvalRequired" />참여할 때 내 승인 받기</label
><button class="button primary" @click="createSpace" :disabled="busy"> >
<Plus :size="18" />연결 공간 만들기 <button class="button primary" type="submit" :disabled="busy">
만들기 <ArrowRight :size="16" />
</button> </button>
</div> </form>
<div v-if="!spaces.length" class="modal-divider"><span>또는 코드로 참여</span></div> <form v-else-if="connectView === 'join'" class="join-form" @submit.prevent="joinSpace">
<form v-if="!spaces.length" @submit.prevent="joinSpace" class="join-form"> <p class="modal-subtitle">상대방에게 받은 초대 코드를 입력하세요.</p>
<input <input
v-model="joinCode" v-model="joinCode"
aria-label="연결 코드" aria-label="연결 코드"
placeholder="8자리 연결 코드" :placeholder="`초대 코드 ${spaceInviteCodeLength}자리`"
maxlength="10" maxlength="10"
autocomplete="off" autocomplete="off"
autocapitalize="characters" autocapitalize="characters"
/><button spellcheck="false"
class="button" />
type="submit" <button class="button primary" type="submit" :disabled="busy || !validJoinCode">
:disabled="busy || joinCode.replace(/[\s-]/g, '').length !== 8" 참여하기 <ArrowRight :size="16" />
> </button>
참여 <ArrowRight :size="16" /> </form>
</button></form <div v-else-if="invite && invite.space.expiresAt > now" class="invite-display">
></template> <img :src="qr" alt="연결 공간 초대 QR 코드" width="208" height="208" />
<div class="invite-code">{{ formatInviteCode(invite.code) }}</div>
<p class="invite-validity">
{{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 유효
</p>
<button class="button invite-copy" :disabled="busy" @click="copyValue(invite.code)">
<Copy :size="16" />코드 복사
</button>
<p class="invite-waiting">다른 기기의 참여를 기다리고 있어요.</p>
</div>
<p v-else class="modal-subtitle">초대가 만료됐어요.</p>
</template>
<template v-else-if="modal === 'about'">
<div class="service-info">
<strong>hop.</strong>
<p>기기 사이를 가볍게.</p>
<p>
파일과 링크를 다른 기기로 보낼 수 있어요.<br />보관함에 저장한 자료는 최대 7일 후
삭제됩니다.
</p>
<small>by noname.company</small>
</div>
</template>
<template v-else <template v-else
><form class="name-form device-settings-form" @submit.prevent="saveName"> ><form class="name-form device-settings-form" @submit.prevent="saveName">
<label <label

View File

@@ -1,6 +1,8 @@
<script setup> <script setup>
import { ref, shallowRef, computed, onMounted, onUnmounted, nextTick } from 'vue'; import { ref, shallowRef, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { Download, X } from '@lucide/vue'; import { Download, X } from '@lucide/vue';
const props = defineProps({ menuItem: Boolean });
const emit = defineEmits(['open', 'closed']);
const standalone = window.matchMedia('(display-mode: standalone)'); const standalone = window.matchMedia('(display-mode: standalone)');
const installed = ref(standalone.matches || navigator.standalone === true); const installed = ref(standalone.matches || navigator.standalone === true);
const prompt = shallowRef(null), const prompt = shallowRef(null),
@@ -11,7 +13,7 @@ const ios =
/iPhone|iPad|iPod/.test(navigator.userAgent) || /iPhone|iPad|iPod/.test(navigator.userAgent) ||
(/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1); (/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
const mobile = ios || /Android/.test(navigator.userAgent); const mobile = ios || /Android/.test(navigator.userAgent);
const visible = computed(() => !installed.value && (mobile || prompt.value)); const visible = computed(() => !installed.value && (props.menuItem || mobile || prompt.value));
function available(event) { function available(event) {
event.preventDefault(); event.preventDefault();
prompt.value = event; prompt.value = event;
@@ -25,6 +27,7 @@ function displayChanged() {
installed.value = standalone.matches || navigator.standalone === true; installed.value = standalone.matches || navigator.standalone === true;
} }
async function install() { async function install() {
emit('open');
if (prompt.value) { if (prompt.value) {
const event = prompt.value; const event = prompt.value;
prompt.value = null; prompt.value = null;
@@ -43,6 +46,7 @@ async function install() {
function close() { function close() {
help.value = false; help.value = false;
button.value?.focus(); button.value?.focus();
emit('closed');
} }
onMounted(() => { onMounted(() => {
window.addEventListener('beforeinstallprompt', available); window.addEventListener('beforeinstallprompt', available);
@@ -60,10 +64,11 @@ onUnmounted(() => {
v-if="visible" v-if="visible"
ref="button" ref="button"
class="pwa-install-button" class="pwa-install-button"
:role="menuItem ? 'menuitem' : undefined"
aria-label="hop. 앱 설치" aria-label="hop. 앱 설치"
@click="install" @click="install"
> >
<Download :size="16" /><span>설치</span> <Download :size="16" /><span>{{ menuItem ? '앱 설치' : '설치' }}</span>
</button> </button>
<Teleport to="body"> <Teleport to="body">
<div v-if="help" class="modal-backdrop" @click.self="close" @keydown.esc.stop="close"> <div v-if="help" class="modal-backdrop" @click.self="close" @keydown.esc.stop="close">

View File

@@ -267,6 +267,9 @@ function submit() {
else if (source.value === 'file') emit('files', [...files.value]); else if (source.value === 'file') emit('files', [...files.value]);
else emit('vault', selectedVault.value); else emit('vault', selectedVault.value);
} }
function fileProgress(index) {
return props.progress?.find((upload) => upload.index === index) ?? props.progress?.[index];
}
function size(value) { function size(value) {
return value >= 1048576 return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB' ? (value / 1048576).toFixed(1) + ' MiB'
@@ -335,6 +338,7 @@ function size(value) {
</div> </div>
<ul class="selected-files"> <ul class="selected-files">
<li v-for="(file, index) in files" :key="index"> <li v-for="(file, index) in files" :key="index">
<div class="selected-file-row">
<FileIcon :size="15" /><span>{{ file.name }}</span <FileIcon :size="15" /><span>{{ file.name }}</span
><small>{{ size(file.size) }}</small ><small>{{ size(file.size) }}</small
><button ><button
@@ -345,8 +349,21 @@ function size(value) {
> >
<X :size="15" /> <X :size="15" />
</button> </button>
</li></ul </div>
></template> <div v-if="fileProgress(index)" class="file-transfer-progress">
<progress
:value="fileProgress(index).progress"
max="100"
:aria-label="`${file.name} 전송 진행률`"
/>
<small>{{ fileProgress(index).progress }}%</small>
<span v-if="fileProgress(index).status === 'failed'" role="status">{{
fileProgress(index).message
}}</span>
</div>
</li>
</ul></template
>
</template> </template>
<template v-else> <template v-else>
<div class="content-heading"> <div class="content-heading">
@@ -399,7 +416,11 @@ function size(value) {
</button></template </button></template
> >
</template> </template>
<p v-if="transfer" class="composer-transfer-state" role="status"> <p
v-if="transfer && (!progress?.length || retryable)"
class="composer-transfer-state"
role="status"
>
{{ {{
retryable retryable
? '같은 파일로 이어 보낼 수 있어요.' ? '같은 파일로 이어 보낼 수 있어요.'
@@ -408,12 +429,13 @@ function size(value) {
: '전송 중입니다.' : '전송 중입니다.'
}} }}
</p> </p>
<div v-for="(upload, index) in progress" :key="index" class="composer-progress"> <template v-if="!files.length"
><div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span <span
>{{ upload.name }} · >{{ upload.name }} ·
{{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span {{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span
><progress :value="upload.progress" max="100" /> ><progress :value="upload.progress" max="100" /></div
</div> ></template>
</div> </div>
<button <button
class="button primary content-submit" class="button primary content-submit"

View File

@@ -28,6 +28,7 @@ export const pendingPeers = ref([]);
let pendingTimer; let pendingTimer;
let deviceInfo = {}; let deviceInfo = {};
export const nextCursor = ref(null); export const nextCursor = ref(null);
export const spaceInviteCodeLength = ref(4);
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 }); export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
export async function publishLocation(position) { export async function publishLocation(position) {
if (!socket?.connected) throw Error('서버 연결을 확인해 주세요.'); if (!socket?.connected) throw Error('서버 연결을 확인해 주세요.');
@@ -150,6 +151,7 @@ export const refresh = createRefreshQueue(async () => {
(id) => !snapshot.devices.some((peer) => peer.id === id), (id) => !snapshot.devices.some((peer) => peer.id === id),
); );
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled; discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
spaceInviteCodeLength.value = snapshot.spaceInviteCodeLength ?? 4;
locationSettings.value = snapshot.locationDiscovery ?? { locationSettings.value = snapshot.locationDiscovery ?? {
enabled: false, enabled: false,
radiusMeters: 100, radiusMeters: 100,
@@ -296,6 +298,7 @@ export async function uploadFile(id, index, file, accountId) {
controller = new AbortController(); controller = new AbortController();
uploadProgress.value[progressKey] = { uploadProgress.value[progressKey] = {
name: file.name, name: file.name,
index,
progress: 0, progress: 0,
status: 'uploading', status: 'uploading',
accountId, accountId,
@@ -407,6 +410,7 @@ export async function uploadFile(id, index, file, accountId) {
check(); check();
uploadProgress.value[progressKey] = { uploadProgress.value[progressKey] = {
name: file.name, name: file.name,
index,
progress: 100, progress: 100,
status: 'stored', status: 'stored',
accountId, accountId,
@@ -417,6 +421,7 @@ export async function uploadFile(id, index, file, accountId) {
else else
uploadProgress.value[progressKey] = { uploadProgress.value[progressKey] = {
name: file.name, name: file.name,
index,
progress: 0, progress: 0,
status: 'failed', status: 'failed',
accountId, accountId,

View File

@@ -1031,7 +1031,7 @@ h1 span {
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgb(16 25 18 / 0.4); background: rgb(0 0 0 / 0.4);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1612,15 +1612,10 @@ h1 span {
list-style: none; list-style: none;
} }
.selected-files li { .selected-files li {
display: flex; display: block;
gap: 8px; margin-bottom: 14px;
align-items: center;
font-size: 11px; font-size: 11px;
} }
.selected-files li > span {
flex: 1;
overflow-wrap: anywhere;
}
.selected-files small { .selected-files small {
color: var(--muted); color: var(--muted);
white-space: nowrap; white-space: nowrap;
@@ -2024,17 +2019,6 @@ h1 span {
} }
} }
.local-qr-note {
max-width: 280px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
font-size: 10px !important;
line-height: 1.8;
text-align: center;
}
.account-security-actions { .account-security-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -3230,3 +3214,242 @@ a.button {
opacity: 0; opacity: 0;
} }
} }
.connect-dialog-title {
display: flex;
align-items: center;
gap: 10px;
}
.connection-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.connection-choice {
min-height: 175px;
border: 1px solid var(--line);
background: var(--surface);
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 18px 12px;
}
.connection-choice:hover {
border-color: var(--muted);
}
.connection-choice strong {
font-size: 15px;
font-weight: 500;
}
.connection-choice span {
font-size: 12px;
color: var(--muted);
}
.modal .join-form,
.modal .create-space {
display: flex;
flex-direction: column;
gap: 18px;
}
.modal .join-form input {
width: 100%;
padding: 14px;
border-radius: 12px;
}
.modal .join-form .button,
.modal .create-space .button {
width: 100%;
border-radius: 24px;
}
.modal .invite-display {
gap: 12px;
}
.modal .invite-code {
font-size: 32px;
letter-spacing: 0.04em;
}
.modal .invite-validity,
.modal .invite-waiting {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.modal .invite-copy {
width: 100%;
border-radius: 24px;
background: var(--line);
margin: 8px 0;
}
@media (max-width: 420px) {
.connection-choice {
min-height: 155px;
padding: 16px 8px;
}
.connection-choice strong {
font-size: 13px;
}
.connection-choice span {
font-size: 11px;
}
}
.selected-file-row {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.selected-file-row > span {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.file-transfer-progress {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin: 8px 0 0 23px;
}
.file-transfer-progress > span {
grid-column: 1 / -1;
font-size: 11px;
color: var(--muted);
overflow-wrap: anywhere;
}
.file-transfer-progress progress {
width: 100%;
height: 5px;
border: 0;
appearance: none;
border-radius: 4px;
overflow: hidden;
background: var(--line);
}
.file-transfer-progress progress::-webkit-progress-bar {
background: var(--line);
border-radius: 4px;
}
.file-transfer-progress progress::-webkit-progress-value {
background: var(--ink);
border-radius: 4px;
}
.file-transfer-progress progress::-moz-progress-bar {
background: var(--ink);
border-radius: 4px;
}
@media (max-width: 760px), (hover: none) and (pointer: coarse) {
input:not([type='checkbox']):not([type='radio']):not([type='file']),
textarea,
select {
font-size: 16px !important;
}
}
@media (max-width: 639px) {
.modal-backdrop {
align-items: flex-end;
padding: 0;
}
.modal-backdrop > .modal {
width: 100%;
max-width: none;
max-height: calc(100dvh - 24px);
border-radius: 22px 22px 0 0;
padding: 22px 20px calc(22px + env(safe-area-inset-bottom));
}
.modal .device-settings-form {
flex-direction: column;
align-items: stretch;
height: auto;
gap: 16px;
}
.modal .device-settings-form label,
.modal .device-settings-form input {
width: 100%;
min-width: 0;
}
.modal .device-settings-form input {
min-height: 44px;
}
.modal .device-settings-form .button {
width: 100%;
min-height: 44px;
}
.modal .discovery-setting {
margin-top: 20px;
align-items: center;
flex-direction: row;
gap: 12px;
}
}
@media (max-width: 639px) {
.modal-backdrop,
.send-backdrop {
top: var(--hop-visible-top, 0px);
bottom: auto;
height: var(--hop-visible-height, 100dvh);
}
.modal-backdrop > .modal,
.send-backdrop .send-dialog {
max-height: calc(var(--hop-visible-height, 100dvh) - 24px);
}
}
.header-more-wrap {
position: relative;
}
.header-more-menu {
position: absolute;
top: calc(100% + 12px);
right: 0;
width: 220px;
max-width: calc(100vw - 40px);
padding: 8px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--soft);
box-shadow: 0 12px 32px #0003;
z-index: 50;
}
.header-more-menu > button {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px;
min-height: 44px;
text-align: left;
border-radius: 8px;
font-size: 13px;
color: var(--ink);
}
.header-more-menu > button:hover {
background: var(--hover);
}
.header-more-menu > button svg {
flex-shrink: 0;
color: var(--muted);
}
.header-more-menu .pwa-install-button span {
display: inline;
}
.service-info {
padding: 24px 0 4px;
font-size: 13px;
line-height: 1.8;
}
.service-info strong {
font-size: 30px;
letter-spacing: -0.07em;
}
.service-info p {
margin: 12px 0;
color: var(--muted);
}
.service-info small {
color: var(--muted);
}

30
apps/web/src/viewport.js Normal file
View File

@@ -0,0 +1,30 @@
import { onMounted, onUnmounted } from 'vue';
// Keep mobile sheets inside the visible area when the software keyboard opens.
export function useVisibleViewport() {
const viewport = window.visualViewport;
let frame;
const update = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const height = viewport?.height ?? innerHeight;
const top = viewport?.offsetTop ?? 0;
document.documentElement.style.setProperty('--hop-visible-height', `${height}px`);
document.documentElement.style.setProperty('--hop-visible-top', `${top}px`);
});
};
onMounted(() => {
viewport?.addEventListener('resize', update);
viewport?.addEventListener('scroll', update);
window.addEventListener('resize', update);
update();
});
onUnmounted(() => {
cancelAnimationFrame(frame);
viewport?.removeEventListener('resize', update);
viewport?.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
document.documentElement.style.removeProperty('--hop-visible-height');
document.documentElement.style.removeProperty('--hop-visible-top');
});
}

View File

@@ -6,7 +6,7 @@
아래 코드 블록을 하나씩 복사하고 Enter를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요. 아래 코드 블록을 하나씩 복사하고 Enter를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요.
이미 배포했다면 **7. 업데이트**부터 진행하세요. 이번 업데이트는 **위치 허용 기반 100m 근처 검색·기종 구분·접속 스켈레톤·퇴장 효과**를 추가하며 기존 DB·업로드 볼륨을 유지합니다. 새 도메인이나 외부 포트는 추가하지 않습니다. 이미 배포했다면 **7. 업데이트**부터 진행하세요. 이번 업데이트는 **자동 근처 검색·기종 구분·연결 방식 선택·4자리 초대 코드·모바일 입력/진행률 개선**를 추가하며 기존 DB·업로드 볼륨을 유지합니다. 새 도메인이나 외부 포트는 추가하지 않습니다.
**‘허용되지 않은 접속 경로’가 나오면:** 주소창을 `https://hop.noname.company`로 바꾸세요. `http://`는 운영 `APP_URL`과 달라 차단됩니다. 5-1단계에서 HTTP를 HTTPS로 자동 이동시킵니다. **‘허용되지 않은 접속 경로’가 나오면:** 주소창을 `https://hop.noname.company`로 바꾸세요. `http://`는 운영 `APP_URL`과 달라 차단됩니다. 5-1단계에서 HTTP를 HTTPS로 자동 이동시킵니다.
@@ -175,8 +175,8 @@ https://hop.noname.company
``` ```
1. PC와 휴대폰을 같은 Wi-Fi에서 열어 주변 기기가 자동으로 보이는지 확인. 1. PC와 휴대폰을 같은 Wi-Fi에서 열어 주변 기기가 자동으로 보이는지 확인.
2. 다른 네트워크의 기기는 **QR로 연결**을 눌러 연결 공간 만들기. 2. 다른 네트워크의 기기는 **다른 기기 연결 → 연결 공간 만들기 → 만들기**. 승인 여부는 만들기 화면에서 선택합니다.
3. 해당 기기에서 QR을 스캔하거나 연결 코드 입력. 3. 해당 기기에서 QR을 스캔하거나 **다른 기기 연결 → 연결 공간 참여 → 4자리 초대 코드 입력 → 참여하기**.
4. 참여 후 QR 모달이 닫히고 상대 기기가 보이는지 확인. 4. 참여 후 QR 모달이 닫히고 상대 기기가 보이는지 확인.
5. 상대 기기 선택 → 링크 전송 → 상대에서 수락 → 링크가 열리는지 확인. 5. 상대 기기 선택 → 링크 전송 → 상대에서 수락 → 링크가 열리는지 확인.
6. 작은 파일 전송 → 상대에서 수락 → 다운로드 확인. 6. 작은 파일 전송 → 상대에서 수락 → 다운로드 확인.
@@ -311,3 +311,13 @@ sh scripts/configure-nas.sh
Docker 앱에서 **새 compose.nas.yaml 다시 불러오기 → 저장 → 재빌드 → 시작 / 재배포** 순서로 진행합니다. 새 YAML은 `pull_policy: never`를 사용합니다. 기존 YAML을 그대로 쓰면 수정이 적용되지 않습니다. Docker 앱에서 **새 compose.nas.yaml 다시 불러오기 → 저장 → 재빌드 → 시작 / 재배포** 순서로 진행합니다. 새 YAML은 `pull_policy: never`를 사용합니다. 기존 YAML을 그대로 쓰면 수정이 적용되지 않습니다.
앱에서 빌드 단계를 사용할 수 없으면 앞의 `sudo docker compose ... build hop` 명령으로 이미지를 먼저 빌드한 뒤 앱에서 시작하세요. `docker login`이나 볼륨 삭제로 해결하려고 하지 마세요. `never`의 로컬 이미지 사용 방식은 [Docker 공식 문서](https://docs.docker.com/reference/compose-file/services/#pull_policy)를 참고했습니다. 앱에서 빌드 단계를 사용할 수 없으면 앞의 `sudo docker compose ... build hop` 명령으로 이미지를 먼저 빌드한 뒤 앱에서 시작하세요. `docker login`이나 볼륨 삭제로 해결하려고 하지 마세요. `never`의 로컬 이미지 사용 방식은 [Docker 공식 문서](https://docs.docker.com/reference/compose-file/services/#pull_policy)를 참고했습니다.
## 초대 코드와 모바일 화면
새 초대 코드는 기본 4자리입니다. 기존 `.env`를 수정하지 않아도 새 이미지에서 적용됩니다. 업데이트 전에 발급한 8자리 코드와 QR은 원래 만료 시간까지 유효합니다.
나중에 초대 코드를 6자리로 바꾸려면 `.env`에 `SPACE_INVITE_CODE_LENGTH=6`을 넣고 YAML 다시 생성 → 프로젝트 편집 → YAML 다시 불러오기 → 재배포합니다. DB나 업로드 볼륨은 그대로 사용합니다.
모바일 기기 설정은 하단 시트로 열리고 입력창을 자동으로 선택하지 않습니다. 사용자가 입력창을 누르면 키보드가 열린 가시 영역에 맞춰 시트 높이를 조정합니다. 로그인·기기명·URL 등 입력 글자는 16px 이상으로 적용하며 화면 확대 기능 자체를 차단하지 않습니다. 파일 전송 중에는 선택한 파일명 아래에 진행률을 표시합니다.
헤더에는 로그인/보관함과 더보기만 표시합니다. **더보기 → 앱 설치 / 테마 / 서비스 정보**에서 보조 기능을 사용하세요.

View File

@@ -97,3 +97,15 @@
- 기존 브라우저 테스트 5종(익명 전송, 계정 보관함, 방 기기 관리, 분할 업로드, PWA) 모두 통과했다. 수락 팝업·자동 열기/다운로드·재시도·7일 보관·2GiB 용량 경계는 유지한다. - 기존 브라우저 테스트 5종(익명 전송, 계정 보관함, 방 기기 관리, 분할 업로드, PWA) 모두 통과했다. 수락 팝업·자동 열기/다운로드·재시도·7일 보관·2GiB 용량 경계는 유지한다.
- 운영 Docker 이미지 빌드 및 재시작 후 SQLite integrity·세션·기기명·파일·미완료 분할 업로드 복구 테스트 통과. 화면 정리 이후에도 같은 검증을 완료했다. - 운영 Docker 이미지 빌드 및 재시작 후 SQLite integrity·세션·기기명·파일·미완료 분할 업로드 복구 테스트 통과. 화면 정리 이후에도 같은 검증을 완료했다.
- NAS 업데이트 순서는 deploy.md에 기록했다. 새 도메인·포트는 필요 없고 실제 NAS에 대한 원격 배포는 수행하지 않았다. - NAS 업데이트 순서는 deploy.md에 기록했다. 새 도메인·포트는 필요 없고 실제 NAS에 대한 원격 배포는 수행하지 않았다.
## 2026-10-12 연결 모달과 모바일 입력 업데이트
- 새 연결 모달은 만들기/참여하기 선택 → 각각의 화면으로 이동한다. 승인 옵션은 만들기 화면에만 표시하고 QR 아래 개발 주소 설명을 제거했다. 코드·유효 시간·복사 버튼을 표시한다.
- 기본 4자리 코드와 설정 변경 후 6자리 발급, 기존 8자리 초대 참여, 충돌 재생성과 재시도 제한을 검증했다. 기존 유일한 코드 해시 제약과 참여 시도 제한을 유지하며 DB 마이그레이션은 추가하지 않았다.
- 전체 빌드·타입 검사, API 테스트 75개 통과. 기존 브라우저 7종 모두 통과.
- 브라우저에서 연결 선택 화면의 두 행동 분리, 승인 옵션 이동, 4자리 입력 검사, 초대 코드·QR 생성과 참여 시 자동 닫힘을 검증했다.
- 모바일 파일 업로드를 실제 진행하면서 파일명이 한 번만 표시되고 바로 아래 진행률 바가 나오며 중복 진행 문구가 없는지 확인했다. 취소·재시도·복수 파일의 전송 인덱스는 유지한다.
- 모바일 로그인 이메일/비밀번호·기기 이름의 실제 계산된 글자 크기 16px, 가로 넘침 없음, 설정 시트의 하단 배치·자동 포커스 없음, 키보드 가시 영역을 줄였을 때 높이 조정을 검증했다. 실제 iPhone Safari 키보드·자동 확대는 사용자 기기에서 최종 확인해야 한다.
- 최신 Docker 이미지 빌드 및 재시작 후 DB·파일·세션·미완료 분할 업로드 복구 검증 통과. NAS 원격 배포는 수행하지 않았다.
- 헤더 보조 기능은 로그인/보관함 뒤 더보기로 모았다. 메뉴의 앱 설치·테마·서비스 정보, 바깥 클릭/Escape 닫힘과 방향키 이동, 모바일 메뉴 경계를 검증했다. 메뉴가 닫혀도 설치 이벤트 수신을 유지해 PWA 설치 기능이 사라지지 않도록 했다.

View File

@@ -50,7 +50,7 @@ export const joinInput = z
.string() .string()
.trim() .trim()
.toUpperCase() .toUpperCase()
.regex(/^[A-Z2-9]{8}$/) .regex(/^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/)
.optional(), .optional(),
token: z token: z
.string() .string()

View File

@@ -48,18 +48,20 @@ try {
} }
await rename(host, '방장 Mac'); await rename(host, '방장 Mac');
await rename(guest, '외부 기기'); await rename(guest, '외부 기기');
await host.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click(); await host.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
await host.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).check();
await host.getByRole('button', { name: '연결 공간 만들기' }).click(); await host.getByRole('button', { name: '연결 공간 만들기' }).click();
await host.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).check();
await host.getByRole('button', { name: '만들기', exact: true }).click();
await host.locator('.invite-code').waitFor(); await host.locator('.invite-code').waitFor();
const code = (await host.locator('.invite-code').innerText()).replace(/\s/g, ''); const code = (await host.locator('.invite-code').innerText()).replace(/\s/g, '');
await host.getByRole('button', { name: '대화상자 닫기' }).click(); await host.getByRole('button', { name: '대화상자 닫기' }).click();
await host.locator('.my-device-info .host-badge').waitFor(); await host.locator('.my-device-info .host-badge').waitFor();
await guest.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click(); await guest.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
await guest.getByRole('button', { name: '연결 공간 참여', exact: true }).click();
await guest.getByRole('textbox', { name: '연결 코드' }).fill(code); await guest.getByRole('textbox', { name: '연결 코드' }).fill(code);
await guest.getByRole('button', { name: '참여', exact: true }).click(); await guest.getByRole('button', { name: '참여하기', exact: true }).click();
await guest.locator('.room-leave').waitFor(); await guest.locator('.room-leave').waitFor();
await guest.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' }); await guest.locator('.modal-backdrop').waitFor({ state: 'hidden' });
const guestCard = host.locator('.peer-entry').filter({ hasText: '외부 기기' }); const guestCard = host.locator('.peer-entry').filter({ hasText: '외부 기기' });
await guestCard.getByText('승인 대기', { exact: true }).waitFor(); await guestCard.getByText('승인 대기', { exact: true }).waitFor();
assert.equal(await guestCard.locator('.peer-card').isDisabled(), true); assert.equal(await guestCard.locator('.peer-card').isDisabled(), true);

View File

@@ -46,6 +46,27 @@ try {
}); });
await page.goto(url); await page.goto(url);
await page.locator('.app-shell[data-online="true"]').waitFor(); await page.locator('.app-shell[data-online="true"]').waitFor();
assert.equal(
await page
.locator('.header-actions > .theme-toggle,.header-actions > .pwa-install-button')
.count(),
0,
);
const more = page.getByRole('button', { name: '더보기', exact: true });
await more.click();
await page.getByRole('menu', { name: '더보기', exact: true }).waitFor();
await page.keyboard.press('End');
assert.equal(await page.evaluate(() => document.activeElement.textContent.trim()), '서비스 정보');
await page.keyboard.press('ArrowUp');
assert.match(await page.evaluate(() => document.activeElement.textContent.trim()), /테마/);
await page.keyboard.press('Escape');
assert.equal(await more.getAttribute('aria-expanded'), 'false');
await more.click();
await page.getByRole('menuitem', { name: '서비스 정보', exact: true }).click();
await page.getByRole('dialog', { name: '서비스 정보', exact: true }).waitFor();
assert.equal(await more.getAttribute('aria-expanded'), 'false');
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
assert.equal(await page.getByText('어디로 보낼까요?', { exact: true }).count(), 0); assert.equal(await page.getByText('어디로 보낼까요?', { exact: true }).count(), 0);
assert.equal(await page.locator('.devices-stage .spin,.section-heading h2 span').count(), 0); assert.equal(await page.locator('.devices-stage .spin,.section-heading h2 span').count(), 0);
await page.screenshot({ path: 'tests/e2e/main-empty-device.png', fullPage: true }); await page.screenshot({ path: 'tests/e2e/main-empty-device.png', fullPage: true });
@@ -165,6 +186,65 @@ try {
assert.equal(linkBounds.width, bounds.width); assert.equal(linkBounds.width, bounds.width);
await page.screenshot({ path: 'tests/e2e/send-sheet.png' }); await page.screenshot({ path: 'tests/e2e/send-sheet.png' });
await page.getByRole('button', { name: '전송 화면 닫기' }).click(); await page.getByRole('button', { name: '전송 화면 닫기' }).click();
await page.locator('.footer-device-summary').click();
const settings = page.getByRole('dialog', { name: '설정', exact: true });
assert.equal(
await page
.getByLabel('기기 이름', { exact: true })
.evaluate((el) => document.activeElement === el),
false,
'모바일 설정에서 키보드를 자동으로 열지 않음',
);
assert.equal(
await page
.getByLabel('기기 이름', { exact: true })
.evaluate((el) => getComputedStyle(el).fontSize),
'16px',
);
const settingsBounds = await settings.boundingBox();
assert(Math.abs(settingsBounds.y + settingsBounds.height - 900) < 2, '모바일 설정 하단 시트');
await page.getByLabel('기기 이름', { exact: true }).focus();
await page.evaluate(() => {
Object.defineProperty(visualViewport, 'height', { configurable: true, value: 480 });
visualViewport.dispatchEvent(new Event('resize'));
});
await page.waitForFunction(
() => document.documentElement.style.getPropertyValue('--hop-visible-height') === '480px',
);
const keyboardBounds = await settings.boundingBox();
assert(
keyboardBounds.y >= 0 && Math.abs(keyboardBounds.y + keyboardBounds.height - 480) < 2,
'키보드가 열린 가시 영역 안의 설정 시트',
);
await page.screenshot({ path: 'tests/e2e/device-settings-mobile-keyboard.png' });
await page.evaluate(() => {
delete visualViewport.height;
visualViewport.dispatchEvent(new Event('resize'));
});
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
await page.getByRole('button', { name: '로그인', exact: false }).first().click();
for (const input of [
page.getByLabel('이메일', { exact: true }),
page.getByLabel('비밀번호', { exact: true }),
]) {
assert.equal(await input.evaluate((el) => getComputedStyle(el).fontSize), '16px');
await input.focus();
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
}
await page.screenshot({ path: 'tests/e2e/login-mobile.png' });
await more.click();
const menuBounds = await page.getByRole('menu', { name: '더보기', exact: true }).boundingBox();
assert(
menuBounds.x >= 0 && menuBounds.x + menuBounds.width <= 390,
'모바일 더보기 메뉴 가로 넘침 없음',
);
await page.screenshot({ path: 'tests/e2e/header-more-mobile.png' });
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'hop. 홈', exact: true }).click();
await page.setViewportSize({ width: 1280, height: 1000 }); await page.setViewportSize({ width: 1280, height: 1000 });
await page.locator('.peer-card').first().click(); await page.locator('.peer-card').first().click();
const dialog = await page.locator('.send-dialog').boundingBox(); const dialog = await page.locator('.send-dialog').boundingBox();
@@ -186,8 +266,32 @@ try {
await page.getByLabel('다른 기기에서 발견 허용').check(); await page.getByLabel('다른 기기에서 발견 허용').check();
await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor(); await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor();
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click(); await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click(); await page.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
await page.getByRole('button', { name: '연결 공간 만들기' }).click(); assert.equal(await page.locator('.connection-choice').count(), 2);
assert.equal(await page.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).count(), 0);
assert.equal(await page.getByLabel('연결 코드', { exact: true }).count(), 0);
await page.screenshot({ path: 'tests/e2e/connection-choose-desktop.png' });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '연결 공간 참여', exact: true }).click();
const joinInput = page.getByLabel('연결 코드', { exact: true });
assert.equal(await joinInput.getAttribute('placeholder'), '초대 코드 4자리');
assert.equal(await joinInput.evaluate((el) => getComputedStyle(el).fontSize), '16px');
await joinInput.fill('ABC');
assert.equal(
await page.getByRole('button', { name: '참여하기', exact: true }).isDisabled(),
true,
);
await joinInput.fill('ABCD');
assert.equal(await page.getByRole('button', { name: '참여하기', exact: true }).isEnabled(), true);
await page.screenshot({ path: 'tests/e2e/connection-join-mobile.png' });
await page.getByRole('button', { name: '연결 방법 선택으로 돌아가기', exact: true }).click();
await page.getByRole('button', { name: '연결 공간 만들기', exact: true }).click();
await page.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).waitFor();
assert.equal(await page.getByLabel('연결 코드', { exact: true }).count(), 0);
await page.getByRole('button', { name: '만들기', exact: true }).click();
assert.match(await page.locator('.invite-code').innerText(), /^[A-HJ-NP-Z2-9]{4}$/);
assert.equal(await page.locator('.local-qr-note,.invite-display .spin').count(), 0);
await page.screenshot({ path: 'tests/e2e/connection-invite-mobile.png' });
const code = (await page.locator('.invite-code').innerText()).replace(/\s/g, ''); const code = (await page.locator('.invite-code').innerText()).replace(/\s/g, '');
const cookie = sockets[1].io.opts.extraHeaders.Cookie; const cookie = sockets[1].io.opts.extraHeaders.Cookie;
const token = cookie.split('=')[1]; const token = cookie.split('=')[1];
@@ -202,13 +306,10 @@ try {
body: JSON.stringify({ code }), body: JSON.stringify({ code }),
}); });
assert.equal(response.status, 200); assert.equal(response.status, 200);
await page.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' }); await page.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 1); await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 1);
await page.screenshot({ path: 'tests/e2e/main-single-device.png', fullPage: true }); await page.screenshot({ path: 'tests/e2e/main-single-device.png', fullPage: true });
assert.equal( assert.equal(await page.getByRole('button', { name: '다른 기기 연결', exact: true }).count(), 0);
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).count(),
0,
);
await page.getByRole('button', { name: '방 나가기', exact: true }).click(); await page.getByRole('button', { name: '방 나가기', exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12); await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
assert.deepEqual(errors, []); assert.deepEqual(errors, []);

View File

@@ -167,7 +167,7 @@ try {
'권한 거절 후 반복 요청 없음', '권한 거절 후 반복 요청 없음',
); );
assert.equal( assert.equal(
await receiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).count(), await receiver.getByRole('button', { name: '다른 기기 연결', exact: true }).count(),
1, 1,
); );
assert.deepEqual(await sender.evaluate(() => caches.keys()), []); assert.deepEqual(await sender.evaluate(() => caches.keys()), []);

View File

@@ -83,16 +83,18 @@ try {
await sender.getByRole('textbox', { name: '기기 이름' }).fill('보내는 Mac'); await sender.getByRole('textbox', { name: '기기 이름' }).fill('보내는 Mac');
await sender.getByRole('button', { name: '이름 저장' }).click(); await sender.getByRole('button', { name: '이름 저장' }).click();
await sender.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor(); await sender.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor();
await sender.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click(); await sender.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
await sender.getByRole('button', { name: '연결 공간 만들기' }).click(); await sender.getByRole('button', { name: '연결 공간 만들기' }).click();
await sender.getByRole('button', { name: '만들기', exact: true }).click();
await sender.locator('.invite-code').waitFor(); await sender.locator('.invite-code').waitFor();
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, ''); const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
await receiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click(); await receiver.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
await receiver.getByRole('button', { name: '연결 공간 참여', exact: true }).click();
await receiver.getByRole('textbox', { name: '연결 코드' }).fill(code); await receiver.getByRole('textbox', { name: '연결 코드' }).fill(code);
await receiver.getByRole('button', { name: '참여', exact: true }).click(); await receiver.getByRole('button', { name: '참여하기', exact: true }).click();
await receiver.locator('.room-leave').waitFor(); await receiver.locator('.room-leave').waitFor();
await sender.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' }); await sender.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await receiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' }); await receiver.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await sender.locator('.peer-card').first().waitFor(); await sender.locator('.peer-card').first().waitFor();
await sender.locator('.peer-card').first().click(); await sender.locator('.peer-card').first().click();
const secret = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E'; const secret = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
@@ -262,6 +264,13 @@ try {
uploadCaptured, uploadCaptured,
new Promise((_, reject) => setTimeout(() => reject(Error('업로드 시작 대기 실패')), 10000)), new Promise((_, reject) => setTimeout(() => reject(Error('업로드 시작 대기 실패')), 10000)),
]); ]);
await sender.setViewportSize({ width: 390, height: 844 });
await sender.locator('.file-transfer-progress progress').waitFor();
const listText = await sender.locator('.content-pane').innerText();
assert.equal(listText.split('browser.txt').length - 1, 1, '파일명은 목록에 한 번만 표시');
assert(!listText.includes('전송 중입니다.'), '진행률이 있으면 전송 중 안내를 반복하지 않음');
assert.equal(await sender.locator('.composer-progress').count(), 0);
await sender.screenshot({ path: 'tests/e2e/file-progress-mobile.png' });
await sender.locator('.send-button').filter({ hasText: '전송 취소' }).click(); await sender.locator('.send-button').filter({ hasText: '전송 취소' }).click();
releaseUpload(); releaseUpload();
await receiver.locator('.receiving-card').waitFor({ state: 'hidden' }); await receiver.locator('.receiving-card').waitFor({ state: 'hidden' });
@@ -277,14 +286,17 @@ try {
await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor), await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor),
'rgb(0, 0, 0)', 'rgb(0, 0, 0)',
); );
await sender.setViewportSize({ width: 1440, height: 1050 });
await sender.screenshot({ path: 'tests/e2e/desktop.png', fullPage: true }); await sender.screenshot({ path: 'tests/e2e/desktop.png', fullPage: true });
await sender.getByRole('button', { name: '라이트 테마로 전환', exact: true }).click(); await sender.getByRole('button', { name: '더보기', exact: true }).click();
await sender.getByRole('menuitem', { name: '라이트 테마로 전환', exact: true }).click();
assert.equal( assert.equal(
await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor), await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor),
'rgb(250, 250, 250)', 'rgb(250, 250, 250)',
); );
await sender.screenshot({ path: 'tests/e2e/light-desktop.png', fullPage: true }); await sender.screenshot({ path: 'tests/e2e/light-desktop.png', fullPage: true });
await sender.getByRole('button', { name: '다크 테마로 전환', exact: true }).click(); await sender.getByRole('button', { name: '더보기', exact: true }).click();
await sender.getByRole('menuitem', { name: '다크 테마로 전환', exact: true }).click();
await receiver.screenshot({ path: 'tests/e2e/mobile.png', fullPage: true }); await receiver.screenshot({ path: 'tests/e2e/mobile.png', fullPage: true });
assert.equal( assert.equal(
await receiver.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), await receiver.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
@@ -303,11 +315,12 @@ try {
await urlReceiver.goto(url); await urlReceiver.goto(url);
await urlReceiver.locator('.device-name').waitFor(); await urlReceiver.locator('.device-name').waitFor();
const receiverCode = (await urlReceiver.locator('.footer-device-code').innerText()).trim(); const receiverCode = (await urlReceiver.locator('.footer-device-code').innerText()).trim();
await urlReceiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click(); await urlReceiver.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
await urlReceiver.getByRole('button', { name: '연결 공간 참여', exact: true }).click();
await urlReceiver.getByRole('textbox', { name: '연결 코드' }).fill(code); await urlReceiver.getByRole('textbox', { name: '연결 코드' }).fill(code);
await urlReceiver.getByRole('button', { name: '참여', exact: true }).click(); await urlReceiver.getByRole('button', { name: '참여하기', exact: true }).click();
await urlReceiver.locator('.room-leave').waitFor(); await urlReceiver.locator('.room-leave').waitFor();
await urlReceiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' }); await urlReceiver.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await second.locator('.peer-card').filter({ hasText: receiverCode }).waitFor(); await second.locator('.peer-card').filter({ hasText: receiverCode }).waitFor();
await second.evaluate(() => { await second.evaluate(() => {
window.__hopClipboard = '일반 텍스트는 전송하지 않습니다.'; window.__hopClipboard = '일반 텍스트는 전송하지 않습니다.';

View File

@@ -94,15 +94,17 @@ try {
await navigate(sender, '기기'); await navigate(sender, '기기');
await navigate(receiver, '기기'); await navigate(receiver, '기기');
assert.equal(await sender.locator('.hero').count(), 0); assert.equal(await sender.locator('.hero').count(), 0);
await sender.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click(); await sender.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
await sender.getByRole('button', { name: '연결 공간 만들기' }).click(); await sender.getByRole('button', { name: '연결 공간 만들기' }).click();
await sender.getByRole('button', { name: '만들기', exact: true }).click();
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, ''); const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
await sender.getByRole('button', { name: '대화상자 닫기' }).click(); await sender.getByRole('button', { name: '대화상자 닫기' }).click();
await receiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click(); await receiver.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
await receiver.getByRole('button', { name: '연결 공간 참여', exact: true }).click();
await receiver.getByRole('textbox', { name: '연결 코드' }).fill(code); await receiver.getByRole('textbox', { name: '연결 코드' }).fill(code);
await receiver.getByRole('button', { name: '참여', exact: true }).click(); await receiver.getByRole('button', { name: '참여하기', exact: true }).click();
await receiver.locator('.room-leave').waitFor(); await receiver.locator('.room-leave').waitFor();
await receiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' }); await receiver.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await sender.locator('.peer-card').first().click(); await sender.locator('.peer-card').first().click();
await sender.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click(); await sender.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await sender.getByLabel('보낼 URL').fill('https://example.com/received'); await sender.getByLabel('보낼 URL').fill('https://example.com/received');

View File

@@ -35,9 +35,16 @@ try {
}); });
const page = await context.newPage(); const page = await context.newPage();
await page.addInitScript(() => { await page.addInitScript(() => {
window.addEventListener('beforeinstallprompt', (event) => { window.addEventListener(
if (!event.__hopTest) { event.preventDefault(); event.stopImmediatePropagation(); } 'beforeinstallprompt',
}, { capture: true }); (event) => {
if (!event.__hopTest) {
event.preventDefault();
event.stopImmediatePropagation();
}
},
{ capture: true },
);
}); });
await page.goto(url); await page.goto(url);
await page.locator('.app-shell[data-online="true"]').waitFor(); await page.locator('.app-shell[data-online="true"]').waitFor();
@@ -48,7 +55,8 @@ try {
assert.equal((await context.request.get(url + icon.src)).status(), 200); assert.equal((await context.request.get(url + icon.src)).status(), 200);
await page.waitForFunction(() => navigator.serviceWorker.controller !== null); await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
assert.deepEqual(await page.evaluate(() => caches.keys()), []); assert.deepEqual(await page.evaluate(() => caches.keys()), []);
const install = page.getByRole('button', { name: 'hop. 앱 설치', exact: true }); await page.getByRole('button', { name: '더보기', exact: true }).click();
const install = page.getByRole('menuitem', { name: 'hop. 앱 설치', exact: true });
await install.click(); await install.click();
await page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor(); await page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor();
assert.match(await page.locator('.install-help').innerText(), /Safari.*공유/s); assert.match(await page.locator('.install-help').innerText(), /Safari.*공유/s);
@@ -67,6 +75,7 @@ try {
event.userChoice = Promise.resolve({ outcome: 'accepted' }); event.userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event); window.dispatchEvent(event);
}); });
await page.getByRole('button', { name: '더보기', exact: true }).click();
await install.click(); await install.click();
assert.equal(await page.evaluate(() => window.__installCalls), 1); assert.equal(await page.evaluate(() => window.__installCalls), 1);
await install.waitFor({ state: 'hidden' }); await install.waitFor({ state: 'hidden' });