Simplify connection flow, mobile transfer UI, and header actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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이며 화면에는 내역을 표시하지 않습니다.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
apps/server/src/inviteCode.js
Normal file
10
apps/server/src/inviteCode.js
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
64
apps/server/test/invite-code.test.js
Normal file
64
apps/server/test/invite-code.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
30
apps/web/src/viewport.js
Normal 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');
|
||||
});
|
||||
}
|
||||
16
deploy.md
16
deploy.md
@@ -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 이상으로 적용하며 화면 확대 기능 자체를 차단하지 않습니다. 파일 전송 중에는 선택한 파일명 아래에 진행률을 표시합니다.
|
||||
|
||||
헤더에는 로그인/보관함과 더보기만 표시합니다. **더보기 → 앱 설치 / 테마 / 서비스 정보**에서 보조 기능을 사용하세요.
|
||||
|
||||
@@ -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 설치 기능이 사라지지 않도록 했다.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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()), []);
|
||||
|
||||
@@ -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 = '일반 텍스트는 전송하지 않습니다.';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user