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
ACCEPTED_UPLOAD_START_HOURS=24
SPACE_RETENTION_MINUTES=10
# Invite codes can be 4 or 6 characters.
SPACE_INVITE_CODE_LENGTH=4
SPACE_MAX_DEVICES=10
OFFLINE_GRACE_MS=3000
MAX_URL_PAYLOAD_SIZE_KB=1024

View File

@@ -93,7 +93,7 @@ QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10
## 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이며 화면에는 내역을 표시하지 않습니다.

View File

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

View File

@@ -18,6 +18,7 @@ export type Config = {
requestSeconds: number;
uploadStartHours: number;
spaceMinutes: number;
spaceInviteCodeLength: number;
spaceMax: number;
offlineGraceMs: 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');
if (num('LOCATION_DISCOVERY_RADIUS_METERS', 100) > 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 {
production,
appUrl,
@@ -92,6 +96,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60),
uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24),
spaceMinutes: num('SPACE_RETENTION_MINUTES', 10),
spaceInviteCodeLength,
spaceMax: num('SPACE_MAX_DEVICES', 10),
offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0),
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 { NearbyLocations } from './nearby.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 type { DB } from './db/index.js';
import * as s from './db/schema.js';
@@ -380,8 +381,18 @@ export class Service {
const now = Date.now(),
id = randomUUID(),
token = randomToken();
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const code = Array.from({ length: 8 }, () => alphabet[randomInt(alphabet.length)]).join('');
// The unique DB constraint includes historical spaces; never reuse their codes.
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
.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 {
ArrowUpRight,
ArrowRight,
ArrowLeft,
LogIn,
Plus,
Monitor,
Smartphone,
@@ -29,19 +31,23 @@ import {
Download,
RotateCcw,
EllipsisVertical,
Ellipsis,
Info,
} from '@lucide/vue';
import QRCode from 'qrcode';
import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
import { createNearbySearch } from './nearbySearch';
import { useVisibleViewport } from './viewport';
useVisibleViewport();
import { createDeliveryReceiver } from './deliveryReceiver';
import { initialTheme, applyTheme as setTheme } from './theme';
import {
device,
account,
connected,
discoveryEnabled,
spaceInviteCodeLength,
locationSettings,
publishLocation,
clearLocation,
@@ -74,12 +80,34 @@ const tab = ref('devices'),
nameDraft = ref(''),
notice = ref(''),
modal = ref(null),
headerMore = ref(false),
connectView = ref('choose'),
joinCode = ref(''),
approvalRequired = ref(false),
invite = ref(null),
qr = ref(''),
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 media = matchMedia('(prefers-color-scheme: dark)');
function applyTheme() {
@@ -133,6 +161,49 @@ async function receiveAction(r, action) {
});
}
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) {
return Boolean(id && spaces.value.some((s) => s.creatorDeviceId === id));
}
@@ -192,8 +263,13 @@ function closeDeviceMenu(restore = false) {
}
function outsideDeviceMenu(event) {
if (!event.target.closest('[data-device-menu]')) closeDeviceMenu();
if (!event.target.closest('[data-header-more]')) closeHeaderMore();
}
function escapeDeviceMenu(event) {
if (event.key === 'Escape' && headerMore.value) {
event.preventDefault();
closeHeaderMore(true);
}
if (event.key === 'Escape' && deviceMenu.value) {
event.preventDefault();
closeDeviceMenu(true);
@@ -436,7 +512,8 @@ async function openDeviceSettings() {
nameDraft.value = device.value?.name ?? '';
modal.value = 'device';
await nextTick();
document.querySelector('.device-settings-form input')?.focus();
if (!matchMedia('(max-width: 639px)').matches)
document.querySelector('.device-settings-form input')?.focus();
}
async function saveName() {
await act(async () => {
@@ -480,12 +557,13 @@ async function createSpace() {
color: { dark: '#111111', light: '#ffffff' },
errorCorrectionLevel: 'M',
});
connectView.value = 'invite';
await refresh();
});
}
async function joinSpace() {
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 = '';
await refresh();
modal.value = null;
@@ -556,16 +634,50 @@ async function spaceAction(space, action, memberId) {
<span class="header-tagline">기기 사이를 가볍게.</span>
</div>
<div class="header-actions">
<InstallButton />
<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'">
<button class="login-button" @click="tab = 'vault'">
{{ account ? '보관함' : '로그인' }} <ArrowUpRight :size="15" />
</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>
</header>
<main>
@@ -807,13 +919,13 @@ async function spaceAction(space, action, memberId) {
v-if="canInvite"
key="qr-connect"
class="qr-connect-card"
:aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'"
:aria-label="spaces.length ? '초대 보기' : '다른 기기 연결'"
@click="modal = 'connect'"
>
<QrCode :size="32" /><strong>{{
spaces.length ? 'QR 초대 보기' : 'QR로 연결'
spaces.length ? '초대 보기' : '다른 기기 연결'
}}</strong
><span>다른 기기 연결하기</span>
><span>QR 또는 코드</span>
</button>
</TransitionGroup>
</section>
@@ -925,58 +1037,108 @@ async function spaceAction(space, action, memberId) {
class="modal"
role="dialog"
aria-modal="true"
:aria-label="modal === 'connect' ? 'QR·코드 연결' : '설정'"
:aria-label="
modal === 'connect' ? connectTitle : modal === 'about' ? '서비스 정보' : '설정'
"
>
<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="대화상자 닫기">
<X :size="20" />
</button>
</header>
<template v-if="modal === 'connect'"
><p class="modal-subtitle">
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
</p>
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
<p class="discovery-status">기기 참여를 기다리고 있어요.</p>
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
<p v-if="localOnlyQr" class="local-qr-note">
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의
다른 브라우저에서 아래 코드를 입력해 주세요.
</p>
<div class="invite-code">{{ invite.code.slice(0, 4) }} {{ invite.code.slice(4) }}</div>
<button class="text-button" @click="copyValue(invite.code)">
<Copy :size="14" />코드 복사
</button>
<p>
{{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 · 최대
10대
</p>
</div>
<div v-else-if="!spaces.length" class="create-space">
<template v-if="modal === 'connect'">
<template v-if="connectView === 'choose'">
<p class="modal-subtitle">서로 다른 네트워크에서도 기기를 연결할 수 있어요.</p>
<div class="connection-choices">
<button
class="connection-choice"
aria-label="연결 공간 만들기"
@click="connectView = 'create'"
>
<QrCode :size="30" /><strong>연결 공간 만들기</strong
><span>QR이나 코드로 초대</span>
</button>
<button
class="connection-choice"
aria-label="연결 공간 참여"
@click="connectView = 'join'"
>
<LogIn :size="30" /><strong>연결 공간 참여</strong><span>초대 코드로 참여</span>
</button>
</div>
</template>
<form
v-else-if="connectView === 'create'"
class="create-space"
@submit.prevent="createSpace"
>
<p class="modal-subtitle">QR이나 초대 코드로 기기를 초대하세요.</p>
<label class="checkbox-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>
</div>
<div v-if="!spaces.length" class="modal-divider"><span>또는 코드로 참여</span></div>
<form v-if="!spaces.length" @submit.prevent="joinSpace" class="join-form">
</form>
<form v-else-if="connectView === 'join'" class="join-form" @submit.prevent="joinSpace">
<p class="modal-subtitle">상대방에게 받은 초대 코드를 입력하세요.</p>
<input
v-model="joinCode"
aria-label="연결 코드"
placeholder="8자리 연결 코드"
:placeholder="`초대 코드 ${spaceInviteCodeLength}자리`"
maxlength="10"
autocomplete="off"
autocapitalize="characters"
/><button
class="button"
type="submit"
:disabled="busy || joinCode.replace(/[\s-]/g, '').length !== 8"
>
참여 <ArrowRight :size="16" />
</button></form
></template>
spellcheck="false"
/>
<button class="button primary" type="submit" :disabled="busy || !validJoinCode">
참여하기 <ArrowRight :size="16" />
</button>
</form>
<div v-else-if="invite && invite.space.expiresAt > now" class="invite-display">
<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
><form class="name-form device-settings-form" @submit.prevent="saveName">
<label

View File

@@ -1,6 +1,8 @@
<script setup>
import { ref, shallowRef, computed, onMounted, onUnmounted, nextTick } from '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 installed = ref(standalone.matches || navigator.standalone === true);
const prompt = shallowRef(null),
@@ -11,7 +13,7 @@ const ios =
/iPhone|iPad|iPod/.test(navigator.userAgent) ||
(/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
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) {
event.preventDefault();
prompt.value = event;
@@ -25,6 +27,7 @@ function displayChanged() {
installed.value = standalone.matches || navigator.standalone === true;
}
async function install() {
emit('open');
if (prompt.value) {
const event = prompt.value;
prompt.value = null;
@@ -43,6 +46,7 @@ async function install() {
function close() {
help.value = false;
button.value?.focus();
emit('closed');
}
onMounted(() => {
window.addEventListener('beforeinstallprompt', available);
@@ -60,10 +64,11 @@ onUnmounted(() => {
v-if="visible"
ref="button"
class="pwa-install-button"
:role="menuItem ? 'menuitem' : undefined"
aria-label="hop. 앱 설치"
@click="install"
>
<Download :size="16" /><span>설치</span>
<Download :size="16" /><span>{{ menuItem ? '앱 설치' : '설치' }}</span>
</button>
<Teleport to="body">
<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 emit('vault', selectedVault.value);
}
function fileProgress(index) {
return props.progress?.find((upload) => upload.index === index) ?? props.progress?.[index];
}
function size(value) {
return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
@@ -335,18 +338,32 @@ function size(value) {
</div>
<ul class="selected-files">
<li v-for="(file, index) in files" :key="index">
<FileIcon :size="15" /><span>{{ file.name }}</span
><small>{{ size(file.size) }}</small
><button
class="icon-button"
:disabled="!mutable"
:aria-label="`${file.name} 선택 해제`"
@click="files.splice(index, 1)"
>
<X :size="15" />
</button>
</li></ul
></template>
<div class="selected-file-row">
<FileIcon :size="15" /><span>{{ file.name }}</span
><small>{{ size(file.size) }}</small
><button
class="icon-button"
:disabled="!mutable"
:aria-label="`${file.name} 선택 해제`"
@click="files.splice(index, 1)"
>
<X :size="15" />
</button>
</div>
<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 v-else>
<div class="content-heading">
@@ -399,7 +416,11 @@ function size(value) {
</button></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
? '같은 파일로 이어 보낼 수 있어요.'
@@ -408,12 +429,13 @@ function size(value) {
: '전송 중입니다.'
}}
</p>
<div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span
>{{ upload.name }} ·
{{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span
><progress :value="upload.progress" max="100" />
</div>
<template v-if="!files.length"
><div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span
>{{ upload.name }} ·
{{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span
><progress :value="upload.progress" max="100" /></div
></template>
</div>
<button
class="button primary content-submit"

View File

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

View File

@@ -1031,7 +1031,7 @@ h1 span {
.modal-backdrop {
position: fixed;
inset: 0;
background: rgb(16 25 18 / 0.4);
background: rgb(0 0 0 / 0.4);
display: flex;
align-items: center;
justify-content: center;
@@ -1612,15 +1612,10 @@ h1 span {
list-style: none;
}
.selected-files li {
display: flex;
gap: 8px;
align-items: center;
display: block;
margin-bottom: 14px;
font-size: 11px;
}
.selected-files li > span {
flex: 1;
overflow-wrap: anywhere;
}
.selected-files small {
color: var(--muted);
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 {
display: flex;
gap: 10px;
@@ -3230,3 +3214,242 @@ a.button {
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를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요.
이미 배포했다면 **7. 업데이트**부터 진행하세요. 이번 업데이트는 **위치 허용 기반 100m 근처 검색·기종 구분·접속 스켈레톤·퇴장 효과**를 추가하며 기존 DB·업로드 볼륨을 유지합니다. 새 도메인이나 외부 포트는 추가하지 않습니다.
이미 배포했다면 **7. 업데이트**부터 진행하세요. 이번 업데이트는 **자동 근처 검색·기종 구분·연결 방식 선택·4자리 초대 코드·모바일 입력/진행률 개선**를 추가하며 기존 DB·업로드 볼륨을 유지합니다. 새 도메인이나 외부 포트는 추가하지 않습니다.
**‘허용되지 않은 접속 경로’가 나오면:** 주소창을 `https://hop.noname.company`로 바꾸세요. `http://`는 운영 `APP_URL`과 달라 차단됩니다. 5-1단계에서 HTTP를 HTTPS로 자동 이동시킵니다.
@@ -175,8 +175,8 @@ https://hop.noname.company
```
1. PC와 휴대폰을 같은 Wi-Fi에서 열어 주변 기기가 자동으로 보이는지 확인.
2. 다른 네트워크의 기기는 **QR로 연결**을 눌러 연결 공간 만들기.
3. 해당 기기에서 QR을 스캔하거나 연결 코드 입력.
2. 다른 네트워크의 기기는 **다른 기기 연결 → 연결 공간 만들기 → 만들기**. 승인 여부는 만들기 화면에서 선택합니다.
3. 해당 기기에서 QR을 스캔하거나 **다른 기기 연결 → 연결 공간 참여 → 4자리 초대 코드 입력 → 참여하기**.
4. 참여 후 QR 모달이 닫히고 상대 기기가 보이는지 확인.
5. 상대 기기 선택 → 링크 전송 → 상대에서 수락 → 링크가 열리는지 확인.
6. 작은 파일 전송 → 상대에서 수락 → 다운로드 확인.
@@ -311,3 +311,13 @@ sh scripts/configure-nas.sh
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)를 참고했습니다.
## 초대 코드와 모바일 화면
새 초대 코드는 기본 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 용량 경계는 유지한다.
- 운영 Docker 이미지 빌드 및 재시작 후 SQLite integrity·세션·기기명·파일·미완료 분할 업로드 복구 테스트 통과. 화면 정리 이후에도 같은 검증을 완료했다.
- 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()
.trim()
.toUpperCase()
.regex(/^[A-Z2-9]{8}$/)
.regex(/^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/)
.optional(),
token: z
.string()

View File

@@ -48,18 +48,20 @@ try {
}
await rename(host, '방장 Mac');
await rename(guest, '외부 기기');
await host.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await host.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).check();
await host.getByRole('button', { name: '다른 기기 연결', exact: true }).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();
const code = (await host.locator('.invite-code').innerText()).replace(/\s/g, '');
await host.getByRole('button', { name: '대화상자 닫기' }).click();
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('button', { name: '참여', exact: true }).click();
await guest.getByRole('button', { name: '참여하기', exact: true }).click();
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: '외부 기기' });
await guestCard.getByText('승인 대기', { exact: true }).waitFor();
assert.equal(await guestCard.locator('.peer-card').isDisabled(), true);

View File

@@ -46,6 +46,27 @@ try {
});
await page.goto(url);
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.locator('.devices-stage .spin,.section-heading h2 span').count(), 0);
await page.screenshot({ path: 'tests/e2e/main-empty-device.png', fullPage: true });
@@ -165,6 +186,65 @@ try {
assert.equal(linkBounds.width, bounds.width);
await page.screenshot({ path: 'tests/e2e/send-sheet.png' });
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.locator('.peer-card').first().click();
const dialog = await page.locator('.send-dialog').boundingBox();
@@ -186,8 +266,32 @@ try {
await page.getByLabel('다른 기기에서 발견 허용').check();
await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor();
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await page.getByRole('button', { name: '연결 공간 만들기' }).click();
await page.getByRole('button', { name: '다른 기기 연결', exact: true }).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 cookie = sockets[1].io.opts.extraHeaders.Cookie;
const token = cookie.split('=')[1];
@@ -202,13 +306,10 @@ try {
body: JSON.stringify({ code }),
});
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.screenshot({ path: 'tests/e2e/main-single-device.png', fullPage: true });
assert.equal(
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).count(),
0,
);
assert.equal(await page.getByRole('button', { name: '다른 기기 연결', exact: true }).count(), 0);
await page.getByRole('button', { name: '방 나가기', exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
assert.deepEqual(errors, []);

View File

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

View File

@@ -83,16 +83,18 @@ try {
await sender.getByRole('textbox', { name: '기기 이름' }).fill('보내는 Mac');
await sender.getByRole('button', { name: '이름 저장' }).click();
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: '만들기', exact: true }).click();
await sender.locator('.invite-code').waitFor();
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('button', { name: '참여', exact: true }).click();
await receiver.getByRole('button', { name: '참여하기', exact: true }).click();
await receiver.locator('.room-leave').waitFor();
await sender.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
await receiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
await sender.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await receiver.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await sender.locator('.peer-card').first().waitFor();
await sender.locator('.peer-card').first().click();
const secret = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
@@ -262,6 +264,13 @@ try {
uploadCaptured,
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();
releaseUpload();
await receiver.locator('.receiving-card').waitFor({ state: 'hidden' });
@@ -277,14 +286,17 @@ try {
await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor),
'rgb(0, 0, 0)',
);
await sender.setViewportSize({ width: 1440, height: 1050 });
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(
await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor),
'rgb(250, 250, 250)',
);
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 });
assert.equal(
await receiver.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
@@ -303,11 +315,12 @@ try {
await urlReceiver.goto(url);
await urlReceiver.locator('.device-name').waitFor();
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('button', { name: '참여', exact: true }).click();
await urlReceiver.getByRole('button', { name: '참여하기', exact: true }).click();
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.evaluate(() => {
window.__hopClipboard = '일반 텍스트는 전송하지 않습니다.';

View File

@@ -94,15 +94,17 @@ try {
await navigate(sender, '기기');
await navigate(receiver, '기기');
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: '만들기', exact: true }).click();
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
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('button', { name: '참여', exact: true }).click();
await receiver.getByRole('button', { name: '참여하기', exact: true }).click();
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.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await sender.getByLabel('보낼 URL').fill('https://example.com/received');

View File

@@ -35,9 +35,16 @@ try {
});
const page = await context.newPage();
await page.addInitScript(() => {
window.addEventListener('beforeinstallprompt', (event) => {
if (!event.__hopTest) { event.preventDefault(); event.stopImmediatePropagation(); }
}, { capture: true });
window.addEventListener(
'beforeinstallprompt',
(event) => {
if (!event.__hopTest) {
event.preventDefault();
event.stopImmediatePropagation();
}
},
{ capture: true },
);
});
await page.goto(url);
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);
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
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 page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor();
assert.match(await page.locator('.install-help').innerText(), /Safari.*공유/s);
@@ -67,6 +75,7 @@ try {
event.userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event);
});
await page.getByRole('button', { name: '더보기', exact: true }).click();
await install.click();
assert.equal(await page.evaluate(() => window.__installCalls), 1);
await install.waitFor({ state: 'hidden' });