chore: remove duplicate copies of the site, download center and installers (WS-C)

The landing page, download pages, invite page, assetlinks and installers
existed in two or three places; only site/ and the Forgejo feed are served.

- apps/api-server/wwwroot: delete the stale site build, download/invite
  pages, .well-known copy, legacy static admin and 1.0.0 binaries. The API
  no longer serves static files (UseStaticFiles/fallbacks and the apk/zip
  blocker removed); the Next admin is the only admin UI.
- Delete 19 tracked installers/packages (~568 MiB) under site/public/releases,
  apps/web/public/releases and wwwroot/releases; .gitignore blocks them.
- apps/web: delete the download/releases pages, desktop-release.ts, the
  download.html and assetlinks copies, and the accept-invite page (invites
  are only issued to the site's /accept-invite/). e2e specs call the /app
  base path and check the /download redirect instead.
- scripts: delete the retired release/NAS site scripts, drop the web target
  from sync-version, and check assetlinks in site/public only.
- Delete the unused Dockerfile.admin (apps/admin/Dockerfile is used).

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-5 and W3-6.
This commit is contained in:
Yun Chan 2026-09-26 15:48:52 +09:00
parent b6fe588a7c
commit cd9d199dbf
53 changed files with 43 additions and 3278 deletions

16
.gitignore vendored
View file

@ -118,9 +118,13 @@ apps/mobile-rn/android/app/src/main/res/drawable-*/node_modules_*
/supabase/.branches/ /supabase/.branches/
/supabase/.temp/ /supabase/.temp/
# Release binaries are not committed: the canonical distribution channel is the # Release binaries are never committed: the only distribution channel is the
# Forgejo update feed, and none of these trees are part of a build graph. # Forgejo update feed (release/update-policy.json). Landing/download/legal/invite
# Historical 1.0.0 copies stay tracked until they are retired. # pages live only in site/, so the API server has no wwwroot at all.
/site/public/releases/*/ /site/public/releases/
/apps/web/public/releases/*/ /apps/web/public/releases/
/apps/api-server/wwwroot/releases/*/ /apps/api-server/wwwroot/
*.apk
*.aab
*.blockmap
D3RO-Voice-Setup-*.exe

View file

@ -1,47 +0,0 @@
# Dockerfile.admin
# Multi-stage production build for @d3ro/admin Next.js App
FROM node:24.19.0-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
COPY packages ./packages
COPY apps/admin ./apps/admin
COPY apps/desktop/package.json ./apps/desktop/package.json
COPY apps/web/package.json ./apps/web/package.json
RUN npm ci
FROM node:24.19.0-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages ./packages
COPY --from=deps /app/apps ./apps
COPY package.json package-lock.json ./
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
RUN npm run build --workspace=@d3ro/admin
FROM node:24.19.0-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV PORT 3001
ENV HOSTNAME "0.0.0.0"
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/apps/admin/public ./apps/admin/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next ./apps/admin/.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/apps/admin/package.json ./apps/admin/package.json
USER nextjs
EXPOSE 3001
CMD ["npm", "run", "start", "--workspace=@d3ro/admin"]

View file

@ -7,14 +7,6 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- Retained legacy binaries are not static assets and must never publish. -->
<Content Remove="wwwroot\releases\**\*" />
<Content Remove="wwwroot\index.html" />
<Content Remove="wwwroot\assets\index-D7M5UQvT.js" />
<Content Remove="wwwroot\assets\index-JlYFxlAJ.js" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />

View file

@ -393,65 +393,9 @@ if (app.Environment.IsDevelopment())
} }
app.UseCors("ConfiguredOrigins"); app.UseCors("ConfiguredOrigins");
app.Use(async (context, next) => // The API serves no static pages. Landing, download, legal, invite and
{ // assetlinks pages live only in site/ (Cloudflare Pages); the admin UI is
var isInvitePage = // apps/admin. Installers are published only through the Forgejo update feed.
context.Request.Path.StartsWithSegments("/accept-invite")
|| context.Request.Path.Equals("/accept-invite.html");
if (isInvitePage)
{
context.Response.OnStarting(() =>
{
// The invite token lives in the query string. Do not cache the page and
// prevent CDN HTML transforms (including analytics script injection).
context.Response.Headers["Cache-Control"] = "no-store, no-transform";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; " +
"form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; " +
"object-src 'none'; script-src 'self'; style-src 'self'";
context.Response.Headers["Permissions-Policy"] =
"camera=(), microphone=(), geolocation=(), payment=(), usb=()";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
return Task.CompletedTask;
});
}
if (context.Request.Path.Equals("/accept-invite"))
{
context.Response.StatusCode = StatusCodes.Status308PermanentRedirect;
context.Response.Headers.Location = $"/accept-invite/{context.Request.QueryString}";
return;
}
await next();
});
app.UseDefaultFiles();
// Historical mobile binaries remain in the checkout for forensics only. They
// are not official releases and must never be reachable through StaticFiles.
app.Use(async (context, next) =>
{
var requestPath = context.Request.Path.Value ?? string.Empty;
var fileName = Path.GetFileName(requestPath);
var legacyMarketingAsset = requestPath.Equals("/assets/index-D7M5UQvT.js", StringComparison.OrdinalIgnoreCase)
|| requestPath.Equals("/assets/index-JlYFxlAJ.js", StringComparison.OrdinalIgnoreCase);
var mobileReleasePath = requestPath.StartsWith("/releases/", StringComparison.OrdinalIgnoreCase)
&& (fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".aab", StringComparison.OrdinalIgnoreCase)
|| (fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
&& (fileName.Contains("android", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("signed", StringComparison.OrdinalIgnoreCase))));
if (mobileReleasePath || legacyMarketingAsset)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next();
});
app.UseStaticFiles();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
@ -480,10 +424,6 @@ app.MapGet("/api/health", () => Results.Ok(new
app.MapControllers(); app.MapControllers();
app.MapFallbackToFile("/accept-invite", "accept-invite.html");
// Fallback to Admin BackOffice UI index.html
app.MapFallbackToFile("/admin/{*path}", "admin/index.html");
app.Run(); app.Run();
public partial class Program { } public partial class Program { }

View file

@ -1,12 +0,0 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.d3ro.voice",
"sha256_cert_fingerprints": [
"01:00:19:21:DB:4F:33:40:85:CE:21:E4:B8:DE:CC:BD:71:DA:87:67:C5:6E:3B:59:83:2A:A1:C8:29:EA:0D:AB"
]
}
}
]

View file

@ -1,345 +0,0 @@
:root {
--ink: #08090c;
--panel: #11141c;
--panel-raised: #171b25;
--line: #303647;
--line-soft: rgba(255, 255, 255, 0.07);
--text: #f4f4f5;
--muted: #a1a1aa;
--faint: #71717a;
--accent: #f25b29;
--accent-hot: #ff7342;
--success: #4ade80;
--danger: #fb7185;
font-family: "Pretendard Variable", Pretendard, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--ink);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
}
body {
background:
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px),
radial-gradient(circle at 72% 18%, rgba(242, 91, 41, 0.13), transparent 34rem),
var(--ink);
background-size: 40px 40px, 40px 40px, auto, auto;
}
button,
a {
font: inherit;
}
a {
color: inherit;
}
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 18px;
}
.invite-panel {
width: min(100%, 720px);
overflow: hidden;
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(145deg, rgba(23, 27, 37, 0.96), rgba(12, 14, 20, 0.98));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48), inset 0 1px rgba(255, 255, 255, 0.06);
}
.brand-row,
.copy-block,
.token-card,
.actions,
.support-row,
.error {
margin-inline: clamp(22px, 7vw, 64px);
}
.brand-row {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
border-bottom: 1px solid var(--line-soft);
}
.brand,
.protocol,
.eyebrow,
.token-card dt,
.token-card dd {
font-family: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
}
.brand {
color: var(--text);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.16em;
text-decoration: none;
}
.protocol {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 10px;
letter-spacing: 0.12em;
}
.signal {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 12px rgba(74, 222, 128, 0.72);
}
.route-line {
height: 54px;
display: grid;
grid-template-columns: auto 1fr auto 1fr auto;
align-items: center;
padding-inline: clamp(22px, 7vw, 64px);
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--line-soft);
}
.route-node {
width: 9px;
height: 9px;
border: 1px solid var(--line);
border-radius: 50%;
background: var(--panel);
}
.route-node--active {
border-color: var(--accent-hot);
background: var(--accent);
box-shadow: 0 0 18px rgba(242, 91, 41, 0.72);
}
.route-track {
height: 1px;
background: linear-gradient(90deg, var(--accent), var(--line));
}
.copy-block {
padding-top: clamp(42px, 8vw, 72px);
}
.eyebrow {
margin: 0 0 16px;
color: var(--accent-hot);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
}
h1 {
max-width: 600px;
margin: 0;
font-size: clamp(34px, 7.2vw, 60px);
font-weight: 790;
letter-spacing: -0.045em;
line-height: 1.08;
text-wrap: balance;
}
.description {
max-width: 590px;
margin: 26px 0 0;
color: #d4d4d8;
font-size: 16px;
line-height: 1.72;
word-break: keep-all;
}
.description--en {
margin-top: 8px;
color: var(--faint);
font-size: 13px;
}
.token-card {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin-top: 38px;
padding: 1px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--line);
overflow: hidden;
}
.token-card div {
min-width: 0;
padding: 18px;
background: var(--panel-raised);
}
.token-card dt {
color: var(--faint);
font-size: 9px;
letter-spacing: 0.13em;
}
.token-card dd {
margin: 8px 0 0;
overflow: hidden;
color: var(--text);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.token-card dd[data-state="valid"] {
color: var(--success);
}
.token-card dd[data-state="invalid"] {
color: var(--danger);
}
.error {
margin-top: 18px;
padding: 13px 14px;
border: 1px solid rgba(251, 113, 133, 0.35);
border-radius: 8px;
color: #fecdd3;
background: rgba(251, 113, 133, 0.08);
font-size: 14px;
line-height: 1.5;
}
.actions {
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 12px;
margin-top: 24px;
}
.button {
min-height: 52px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
padding: 0 18px;
font-weight: 750;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
}
.button--primary {
color: #fff;
background: var(--accent);
box-shadow: 0 10px 30px rgba(242, 91, 41, 0.2);
}
.button--primary:hover {
background: var(--accent-hot);
transform: translateY(-1px);
}
.button--secondary {
color: var(--text);
border-color: var(--line);
background: rgba(255, 255, 255, 0.035);
}
.button--secondary:hover {
border-color: #535f7f;
background: rgba(255, 255, 255, 0.06);
}
.button[aria-disabled="true"],
.button:disabled {
opacity: 0.42;
cursor: not-allowed;
pointer-events: none;
transform: none;
}
.button:focus-visible,
.brand:focus-visible,
.support-row a:focus-visible {
outline: 3px solid rgba(255, 115, 66, 0.78);
outline-offset: 3px;
}
.support-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 38px;
padding-block: 22px 28px;
border-top: 1px solid var(--line-soft);
color: var(--faint);
font-size: 13px;
}
.support-row a {
color: #d4d4d8;
font-weight: 650;
text-underline-offset: 4px;
}
@media (max-width: 560px) {
.shell {
align-items: start;
padding: 12px;
}
.invite-panel {
border-radius: 12px;
}
.brand-row {
min-height: 66px;
}
.protocol {
font-size: 8px;
}
.token-card,
.actions {
grid-template-columns: 1fr;
}
.support-row {
align-items: flex-start;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -1,76 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#08090c" />
<meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
/>
<title>D3RO Voice — 팀 초대 열기</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/accept-invite.css" />
</head>
<body>
<main class="shell">
<section class="invite-panel" aria-labelledby="invite-title">
<header class="brand-row">
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
</header>
<div class="route-line" aria-hidden="true">
<span class="route-node route-node--active"></span>
<span class="route-track"></span>
<span class="route-node"></span>
<span class="route-track"></span>
<span class="route-node"></span>
</div>
<div class="copy-block">
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
<p class="description">
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
저장하거나 수락 결과를 만들지 않아.
</p>
<p class="description description--en" lang="en">
Acceptance is completed only after the app verifies your signed-in account and server permissions.
</p>
</div>
<dl class="token-card" aria-label="초대 링크 상태">
<div>
<dt>LINK STATUS</dt>
<dd id="invite-status" aria-live="polite">검증 중</dd>
</div>
<div>
<dt>TOKEN FINGERPRINT</dt>
<dd id="token-fingerprint">—</dd>
</div>
</dl>
<p id="invite-error" class="error" role="alert" hidden></p>
<div class="actions">
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
D3RO Voice 앱 열기
</a>
<button id="copy-link" class="button button--secondary" type="button" disabled>
초대 링크 복사
</button>
</div>
<footer class="support-row">
<span>앱이 설치되지 않았어?</span>
<a href="/download.html">안전한 설치 파일 받기</a>
</footer>
</section>
</main>
<script src="/accept-invite.js" defer></script>
</body>
</html>

View file

@ -1,37 +0,0 @@
(() => {
'use strict'
const tokenPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const status = document.getElementById('invite-status')
const fingerprint = document.getElementById('token-fingerprint')
const error = document.getElementById('invite-error')
const openApp = document.getElementById('open-app')
const copyLink = document.getElementById('copy-link')
const token = new URLSearchParams(window.location.search).get('token')?.trim() ?? ''
if (!tokenPattern.test(token)) {
status.textContent = '사용할 수 없는 링크'
status.dataset.state = 'invalid'
error.textContent = '초대 링크가 없거나 형식이 올바르지 않아. 새 초대 링크를 요청해.'
error.hidden = false
return
}
status.textContent = '형식 검증 완료 · 서버 확인 대기'
status.dataset.state = 'valid'
fingerprint.textContent = `${token.slice(0, 8)}…${token.slice(-4)}`
openApp.href = `d3ro-voice://accept-invite?token=${encodeURIComponent(token)}`
openApp.removeAttribute('aria-disabled')
copyLink.disabled = false
copyLink.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(window.location.href)
copyLink.textContent = '복사했어'
window.setTimeout(() => { copyLink.textContent = '초대 링크 복사' }, 1800)
} catch {
error.textContent = '브라우저가 복사를 허용하지 않았어. 주소 표시줄에서 링크를 직접 복사해.'
error.hidden = false
}
})
})()

View file

@ -1,76 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#08090c" />
<meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
/>
<title>D3RO Voice — 팀 초대 열기</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/accept-invite.css" />
</head>
<body>
<main class="shell">
<section class="invite-panel" aria-labelledby="invite-title">
<header class="brand-row">
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
</header>
<div class="route-line" aria-hidden="true">
<span class="route-node route-node--active"></span>
<span class="route-track"></span>
<span class="route-node"></span>
<span class="route-track"></span>
<span class="route-node"></span>
</div>
<div class="copy-block">
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
<p class="description">
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
저장하거나 수락 결과를 만들지 않아.
</p>
<p class="description description--en" lang="en">
Acceptance is completed only after the app verifies your signed-in account and server permissions.
</p>
</div>
<dl class="token-card" aria-label="초대 링크 상태">
<div>
<dt>LINK STATUS</dt>
<dd id="invite-status" aria-live="polite">검증 중</dd>
</div>
<div>
<dt>TOKEN FINGERPRINT</dt>
<dd id="token-fingerprint">—</dd>
</div>
</dl>
<p id="invite-error" class="error" role="alert" hidden></p>
<div class="actions">
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
D3RO Voice 앱 열기
</a>
<button id="copy-link" class="button button--secondary" type="button" disabled>
초대 링크 복사
</button>
</div>
<footer class="support-row">
<span>앱이 설치되지 않았어?</span>
<a href="/download.html">안전한 설치 파일 받기</a>
</footer>
</section>
</main>
<script src="/accept-invite.js" defer></script>
</body>
</html>

View file

@ -1,730 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>D3RO Voice — Admin BackOffice</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
:root {
--bg-gradient: radial-gradient(circle at 50% 0%, #171b26 0%, #0b0d13 100%);
--card-bg: rgba(22, 27, 38, 0.7);
--card-border: rgba(255, 255, 255, 0.08);
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.35);
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-gradient);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Layout Header */
header {
border-bottom: 1px solid var(--card-border);
backdrop-filter: blur(12px);
background: rgba(11, 13, 19, 0.8);
position: sticky;
top: 0;
z-index: 100;
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-family: 'Outfit', sans-serif;
font-weight: 700;
font-size: 20px;
letter-spacing: -0.5px;
}
.brand-badge {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
font-family: 'Inter', sans-serif;
font-weight: 600;
}
nav {
display: flex;
gap: 8px;
}
.nav-btn {
background: transparent;
border: none;
color: var(--text-muted);
padding: 8px 16px;
border-radius: 8px;
font-weight: 500;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.nav-btn:hover, .nav-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.06);
}
.nav-btn.active {
border: 1px solid var(--accent-glow);
color: var(--accent);
}
/* Main Container */
main {
flex: 1;
max-width: 1400px;
margin: 0 auto;
width: 100%;
padding: 32px 24px;
}
.page-section { display: none; }
.page-section.active { display: block; animation: fadeIn 0.3s ease-out; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Metric Cards Grid */
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.card-title {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-value {
font-family: 'Outfit', sans-serif;
font-size: 32px;
font-weight: 700;
color: #fff;
}
.card-sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
/* Data Tables */
.table-container {
width: 100%;
overflow-x: auto;
margin-top: 16px;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 14px;
}
th {
padding: 14px 16px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
font-weight: 600;
border-bottom: 1px solid var(--card-border);
}
td {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
color: var(--text-main);
}
tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.badge-success { background: rgba(16, 185, 129, 0.15); color: var(--success); }
.badge-danger { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
.badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
.badge-primary { background: rgba(99, 102, 241, 0.15); color: var(--accent); }
/* Action Buttons */
.btn {
background: var(--accent);
color: #fff;
border: none;
padding: 10px 18px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.btn:hover { background: var(--accent-hover); box-shadow: 0 0 16px var(--accent-glow); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-danger { background: rgba(239, 68, 68, 0.2); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.4); }
.btn-danger:hover { background: var(--danger); color: #fff; }
/* Section Headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-title {
font-family: 'Outfit', sans-serif;
font-size: 22px;
font-weight: 700;
}
/* Modal Form */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-overlay.active { display: flex; }
.modal-box {
background: #131722;
border: 1px solid var(--card-border);
border-radius: 20px;
width: 100%;
max-width: 540px;
padding: 32px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-muted);
margin-bottom: 6px;
}
.form-control {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--card-border);
color: #fff;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
outline: none;
}
.form-control:focus {
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="brand">
<i data-lucide="shield-check" style="color: var(--accent);"></i>
<span>D3RO VOICE</span>
<span class="brand-badge">BackOffice API Server</span>
</div>
<nav>
<button class="nav-btn active" onclick="showTab('dashboard')"><i data-lucide="layout-dashboard"></i> 대시보드</button>
<button class="nav-btn" onclick="showTab('users')"><i data-lucide="users"></i> 사용자 관리</button>
<button class="nav-btn" onclick="showTab('endpoints')"><i data-lucide="cpu"></i> 서비스 모델 리스트</button>
<button class="nav-btn" onclick="showTab('usage')"><i data-lucide="bar-chart-3"></i> 상세 Usage & 비용</button>
</nav>
</div>
</header>
<main>
<!-- 1. DASHBOARD -->
<section id="dashboard" class="page-section active">
<div class="section-header">
<h2 class="section-title">백엔드 서버 대시보드</h2>
<button class="btn btn-sm" onclick="loadStats()"><i data-lucide="refresh-cw"></i> 새로고침</button>
</div>
<div class="grid-4">
<div class="card">
<div class="card-title">서버 상태 & 업타임 <i data-lucide="activity" style="color: var(--success);"></i></div>
<div class="card-value" id="val-uptime">0s</div>
<div class="card-sub" id="val-health"><span class="badge badge-success">정상 작동 중</span></div>
</div>
<div class="card">
<div class="card-title">총 사용자 수 <i data-lucide="user-check" style="color: var(--accent);"></i></div>
<div class="card-value" id="val-users">0</div>
<div class="card-sub" id="val-active-today">오늘 접속: 0명</div>
</div>
<div class="card">
<div class="card-title">API 요청 처리량 <i data-lucide="zap" style="color: var(--warning);"></i></div>
<div class="card-value" id="val-requests">0</div>
<div class="card-sub">누적 AI API 호출 수</div>
</div>
<div class="card">
<div class="card-title">총 사용량 & 예상 비용 <i data-lucide="dollar-sign" style="color: var(--success);"></i></div>
<div class="card-value" id="val-cost">$0.0000</div>
<div class="card-sub">실시간 토큰 비용 계산</div>
</div>
</div>
<div class="card">
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">운영 에러 & 서비스 로그</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>에러 유형</th>
<th>메시지</th>
<th>엔드포인트</th>
<th>발생 일시</th>
</tr>
</thead>
<tbody id="tbody-errors">
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">로그 데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- 2. USERS -->
<section id="users" class="page-section">
<div class="section-header">
<h2 class="section-title">가입 사용자 목록</h2>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>이메일</th>
<th>권한</th>
<th>상태</th>
<th>가입 일시</th>
<th>최근 로그인</th>
</tr>
</thead>
<tbody id="tbody-users">
<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">사용자 목록을 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- 3. MODEL ENDPOINTS -->
<section id="endpoints" class="page-section">
<div class="section-header">
<h2 class="section-title">서비스 제공 모델 & 엔드포인트 설정</h2>
<button class="btn" onclick="openModelModal()"><i data-lucide="plus"></i> 모델 엔드포인트 추가</button>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>모델 ID</th>
<th>모델 이름</th>
<th>제공자</th>
<th>엔드포인트 URL</th>
<th>입력 토큰 비용 ($/1k)</th>
<th>출력 토큰 비용 ($/1k)</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody id="tbody-endpoints">
<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">모델 엔드포인트 데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- 4. USAGE & COST -->
<section id="usage" class="page-section">
<div class="section-header">
<h2 class="section-title">상세 Usage & 비용 분석 (BE Home)</h2>
</div>
<div class="grid-4" style="margin-bottom: 24px;">
<div class="card">
<div class="card-title">총 프롬프트 토큰</div>
<div class="card-value" id="val-p-tokens">0</div>
</div>
<div class="card">
<div class="card-title">총 완성 토큰</div>
<div class="card-value" id="val-c-tokens">0</div>
</div>
<div class="card">
<div class="card-title">총 토큰 합계</div>
<div class="card-value" id="val-t-tokens">0</div>
</div>
<div class="card">
<div class="card-title">누적 과금 금액</div>
<div class="card-value" id="val-total-cost">$0.0000</div>
</div>
</div>
<div class="card" style="margin-bottom: 24px;">
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">사용자별 사용량 및 비용</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>사용자 ID</th>
<th>이메일</th>
<th>호출 횟수</th>
<th>사용 토큰</th>
<th>계산 비용 ($)</th>
</tr>
</thead>
<tbody id="tbody-user-usage">
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용량 리포트를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">모델 엔드포인트별 사용량</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>모델 ID</th>
<th>모델 이름</th>
<th>호출 횟수</th>
<th>총 토큰</th>
<th>계산 비용 ($)</th>
</tr>
</thead>
<tbody id="tbody-model-usage">
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용량 리포트를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<!-- ADD / EDIT ENDPOINT MODAL -->
<div class="modal-overlay" id="modelModal">
<div class="modal-box">
<div class="modal-header">
<h3 style="font-family: 'Outfit', sans-serif; font-size: 20px;">서비스 모델 엔드포인트 추가</h3>
<button onclick="closeModelModal()" style="background:none; border:none; color:var(--text-muted); cursor:pointer;"><i data-lucide="x"></i></button>
</div>
<form id="endpointForm" onsubmit="handleSaveEndpoint(event)">
<div class="form-group">
<label>모델 ID (영문 식별자)</label>
<input type="text" id="m-id" class="form-control" placeholder="예: d3ro-custom-model" required>
</div>
<div class="form-group">
<label>모델 Display 이름</label>
<input type="text" id="m-name" class="form-control" placeholder="예: D3RO Premium Custom LLM" required>
</div>
<div class="form-row">
<div class="form-group">
<label>제공자 (Provider)</label>
<input type="text" id="m-provider" class="form-control" placeholder="OpenAI / Custom" required>
</div>
<div class="form-group">
<label>API Key (선택)</label>
<input type="password" id="m-key" class="form-control" placeholder="sk-...">
</div>
</div>
<div class="form-group">
<label>엔드포인트 URL</label>
<input type="url" id="m-url" class="form-control" placeholder="https://api.openai.com/v1/chat/completions" required>
</div>
<div class="form-row">
<div class="form-group">
<label>입력 토큰 비용 ($/1k)</label>
<input type="number" step="0.000001" id="m-cost-p" class="form-control" value="0.000150" required>
</div>
<div class="form-group">
<label>출력 토큰 비용 ($/1k)</label>
<input type="number" step="0.000001" id="m-cost-c" class="form-control" value="0.000600" required>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
<button type="button" class="btn" style="background: transparent; border: 1px solid var(--card-border);" onclick="closeModelModal()">취소</button>
<button type="submit" class="btn"><i data-lucide="check"></i> 저장하기</button>
</div>
</form>
</div>
</div>
<script>
lucide.createIcons();
function showTab(tabId) {
document.querySelectorAll('.nav-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.page-section').forEach(sec => sec.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById(tabId).classList.add('active');
if (tabId === 'dashboard') loadStats();
if (tabId === 'users') loadUsers();
if (tabId === 'endpoints') loadEndpoints();
if (tabId === 'usage') loadUsageReport();
}
async function loadStats() {
try {
const res = await fetch('/api/admin/stats');
if (!res.ok) return;
const data = await res.json();
document.getElementById('val-uptime').innerText = Math.floor(data.serverUptimeSeconds) + '초';
document.getElementById('val-users').innerText = data.totalUsers;
document.getElementById('val-active-today').innerText = '오늘 접속: ' + data.activeUsersToday + '명';
document.getElementById('val-requests').innerText = data.totalRequests;
document.getElementById('val-cost').innerText = '$' + data.totalCost.toFixed(4);
const tbody = document.getElementById('tbody-errors');
if (data.recentErrors.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--success);">에러 로그 없음 (시스템 정상)</td></tr>';
} else {
tbody.innerHTML = data.recentErrors.map(e => `
<tr>
<td>${e.id}</td>
<td><span class="badge badge-danger">${e.errorType}</span></td>
<td>${e.message}</td>
<td>${e.endpoint || '-'}</td>
<td>${new Date(e.createdAt).toLocaleString()}</td>
</tr>
`).join('');
}
} catch (err) {
console.error(err);
}
}
async function loadUsers() {
try {
const res = await fetch('/api/admin/users');
if (!res.ok) return;
const users = await res.json();
const tbody = document.getElementById('tbody-users');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">가입된 사용자가 없습니다.</td></tr>';
} else {
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td style="font-weight: 600;">${u.email}</td>
<td><span class="badge ${u.role === 'Admin' ? 'badge-primary' : 'badge-warning'}">${u.role}</span></td>
<td><span class="badge ${u.isActive ? 'badge-success' : 'badge-danger'}">${u.isActive ? '활성' : '비활성'}</span></td>
<td>${new Date(u.createdAt).toLocaleString()}</td>
<td>${u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '미접속'}</td>
</tr>
`).join('');
}
} catch (err) {
console.error(err);
}
}
async function loadEndpoints() {
try {
const res = await fetch('/api/admin/endpoints');
if (!res.ok) return;
const endpoints = await res.json();
const tbody = document.getElementById('tbody-endpoints');
if (endpoints.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">등록된 서비스 모델 엔드포인트가 없습니다.</td></tr>';
} else {
tbody.innerHTML = endpoints.map(e => `
<tr>
<td><code>${e.modelId}</code></td>
<td style="font-weight:600;">${e.modelName}</td>
<td><span class="badge badge-primary">${e.provider}</span></td>
<td style="font-size:12px; color:var(--text-muted);">${e.endpointUrl}</td>
<td>$${e.costPer1kPromptTokens.toFixed(6)}</td>
<td>$${e.costPer1kCompletionTokens.toFixed(6)}</td>
<td><span class="badge ${e.isActive ? 'badge-success' : 'badge-danger'}">${e.isActive ? '제공중' : '중지'}</span></td>
<td>
<button class="btn btn-sm btn-danger" onclick="deleteEndpoint(${e.id})">삭제</button>
</td>
</tr>
`).join('');
}
} catch (err) {
console.error(err);
}
}
async function loadUsageReport() {
try {
const res = await fetch('/api/admin/usage');
if (!res.ok) return;
const report = await res.json();
document.getElementById('val-p-tokens').innerText = report.totalPromptTokens.toLocaleString();
document.getElementById('val-c-tokens').innerText = report.totalCompletionTokens.toLocaleString();
document.getElementById('val-t-tokens').innerText = (report.totalPromptTokens + report.totalCompletionTokens).toLocaleString();
document.getElementById('val-total-cost').innerText = '$' + report.totalCost.toFixed(4);
const tbodyUser = document.getElementById('tbody-user-usage');
if (report.userSummaries.length === 0) {
tbodyUser.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용 이력이 없습니다.</td></tr>';
} else {
tbodyUser.innerHTML = report.userSummaries.map(u => `
<tr>
<td>${u.userId}</td>
<td>${u.email}</td>
<td>${u.totalRequests}회</td>
<td>${u.totalTokens.toLocaleString()}</td>
<td style="font-weight:700; color:var(--success);">$${u.totalCost.toFixed(6)}</td>
</tr>
`).join('');
}
const tbodyModel = document.getElementById('tbody-model-usage');
if (report.modelSummaries.length === 0) {
tbodyModel.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">모델 사용 이력이 없습니다.</td></tr>';
} else {
tbodyModel.innerHTML = report.modelSummaries.map(m => `
<tr>
<td><code>${m.modelId}</code></td>
<td>${m.modelName}</td>
<td>${m.totalRequests}회</td>
<td>${m.totalTokens.toLocaleString()}</td>
<td style="font-weight:700; color:var(--success);">$${m.totalCost.toFixed(6)}</td>
</tr>
`).join('');
}
} catch (err) {
console.error(err);
}
}
function openModelModal() {
document.getElementById('modelModal').classList.add('active');
}
function closeModelModal() {
document.getElementById('modelModal').classList.remove('active');
}
async function handleSaveEndpoint(e) {
e.preventDefault();
const payload = {
modelId: document.getElementById('m-id').value,
modelName: document.getElementById('m-name').value,
provider: document.getElementById('m-provider').value,
apiKey: document.getElementById('m-key').value,
endpointUrl: document.getElementById('m-url').value,
costPer1kPromptTokens: parseFloat(document.getElementById('m-cost-p').value),
costPer1kCompletionTokens: parseFloat(document.getElementById('m-cost-c').value)
};
try {
const res = await fetch('/api/admin/endpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeModelModal();
loadEndpoints();
document.getElementById('endpointForm').reset();
} else {
const err = await res.json();
alert(err.message || '저장 실패');
}
} catch (err) {
alert('에러 발생: ' + err.message);
}
}
async function deleteEndpoint(id) {
if (!confirm('이 서비스 모델 엔드포인트를 삭제하시겠습니까?')) return;
try {
const res = await fetch('/api/admin/endpoints/' + id, { method: 'DELETE' });
if (res.ok) loadEndpoints();
} catch (err) {
alert('삭제 실패: ' + err.message);
}
}
// Initialize
loadStats();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,17 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
<title>D3RO Voice 릴리스 준비</title>
</head>
<body>
<main>
<h1>D3RO Voice 릴리스 준비</h1>
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
</main>
</body>
</html>

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#19191b"/>
<circle cx="32" cy="28" r="8" fill="none" stroke="#f25b29" stroke-width="3"/>
<rect x="29" y="36" width="6" height="10" rx="3" fill="#f25b29"/>
<path d="M20 32 Q20 44 32 44 Q44 44 44 32" fill="none" stroke="#f25b29" stroke-width="2.5" stroke-linecap="round"/>
<line x1="32" y1="44" x2="32" y2="52" stroke="#f25b29" stroke-width="2.5" stroke-linecap="round"/>
<line x1="26" y1="52" x2="38" y2="52" stroke="#f25b29" stroke-width="2.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 592 B

View file

@ -1,17 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
<title>D3RO Voice 릴리스 준비</title>
</head>
<body>
<main>
<h1>D3RO Voice 릴리스 준비</h1>
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
</main>
</body>
</html>

View file

@ -1,8 +0,0 @@
version: 1.0.0
files:
- url: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
size: 102123822
path: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
releaseDate: '2026-08-20T01:56:59.299Z'

View file

@ -1,8 +0,0 @@
version: 1.0.0
files:
- url: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
size: 102123822
path: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
releaseDate: '2026-08-20T01:56:59.299Z'

View file

@ -124,12 +124,12 @@ test.describe('Billing provider and Payple DOM flow', () => {
}) })
}) })
await page.goto('/login') await page.goto('/app/login')
await page.getByPlaceholder('user@studio.com').fill(email) await page.getByPlaceholder('user@studio.com').fill(email)
await page.getByPlaceholder('••••••••').fill(password) await page.getByPlaceholder('••••••••').fill(password)
await page.getByRole('button', { name: '로그인', exact: true }).click() await page.getByRole('button', { name: '로그인', exact: true }).click()
await page.waitForURL(/\/dashboard/, { timeout: 15_000 }) await page.waitForURL(/\/dashboard/, { timeout: 15_000 })
await page.goto('/billing') await page.goto('/app/billing')
await expect(page.getByTestId('billing-current-tier')).toHaveText('FREE') await expect(page.getByTestId('billing-current-tier')).toHaveText('FREE')
await expect(page.getByTestId('billing-current-provider')).toContainText('없음') await expect(page.getByTestId('billing-current-provider')).toContainText('없음')

View file

@ -35,7 +35,7 @@ test.describe('Dashboard, dictionary and command Supabase parity', () => {
let otherId = '' let otherId = ''
async function login(page: import('@playwright/test').Page): Promise<void> { async function login(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/login') await page.goto('/app/login')
await page.getByPlaceholder('user@studio.com').fill(ownerEmail) await page.getByPlaceholder('user@studio.com').fill(ownerEmail)
await page.getByPlaceholder('••••••••').fill(password) await page.getByPlaceholder('••••••••').fill(password)
await page.getByRole('button', { name: '로그인', exact: true }).click() await page.getByRole('button', { name: '로그인', exact: true }).click()
@ -146,7 +146,7 @@ test.describe('Dashboard, dictionary and command Supabase parity', () => {
test('dictionary uses RLS CRUD, filters, pagination, duplicate protection and optimistic rollback', async ({ page }) => { test('dictionary uses RLS CRUD, filters, pagination, duplicate protection and optimistic rollback', async ({ page }) => {
await login(page) await login(page)
await page.goto('/dictionary') await page.goto('/app/dictionary')
await expect(page.getByText('CUSTOM DICTIONARY')).toBeVisible() await expect(page.getByText('CUSTOM DICTIONARY')).toBeVisible()
await expect(page.getByText(foreignDictionaryWord)).toHaveCount(0) await expect(page.getByText(foreignDictionaryWord)).toHaveCount(0)
await expect(page.getByText(oldestWord)).toHaveCount(0) await expect(page.getByText(oldestWord)).toHaveCount(0)
@ -237,7 +237,7 @@ test.describe('Dashboard, dictionary and command Supabase parity', () => {
expect(bootstrapA.data).toHaveLength(4) expect(bootstrapA.data).toHaveLength(4)
expect(bootstrapB.data).toHaveLength(4) expect(bootstrapB.data).toHaveLength(4)
await bootstrapClient.auth.signOut({ scope: 'local' }) await bootstrapClient.auth.signOut({ scope: 'local' })
await page.goto('/commands') await page.goto('/app/commands')
await expect(page.getByText('SYNCED COMMANDS (4)')).toBeVisible() await expect(page.getByText('SYNCED COMMANDS (4)')).toBeVisible()
await expect(page.getByText('Translate to English', { exact: true }).first()).toBeVisible() await expect(page.getByText('Translate to English', { exact: true }).first()).toBeVisible()
await expect(page.getByText('BUILT-IN · READ ONLY').first()).toBeVisible() await expect(page.getByText('BUILT-IN · READ ONLY').first()).toBeVisible()

View file

@ -90,13 +90,13 @@ test.describe('History Supabase SSOT', () => {
}) })
test('session, RLS, search, favorite, pagination, detail and mutation rollback work end-to-end', async ({ page }) => { test('session, RLS, search, favorite, pagination, detail and mutation rollback work end-to-end', async ({ page }) => {
await page.goto('/login') await page.goto('/app/login')
await page.getByPlaceholder('user@studio.com').fill(ownerEmail) await page.getByPlaceholder('user@studio.com').fill(ownerEmail)
await page.getByPlaceholder('••••••••').fill(password) await page.getByPlaceholder('••••••••').fill(password)
await page.getByRole('button', { name: '로그인', exact: true }).click() await page.getByRole('button', { name: '로그인', exact: true }).click()
await page.waitForURL(/\/dashboard/, { timeout: 15_000 }) await page.waitForURL(/\/dashboard/, { timeout: 15_000 })
await page.goto('/history') await page.goto('/app/history')
await expect(page.getByText('TRANSCRIPTION HISTORY')).toBeVisible() await expect(page.getByText('TRANSCRIPTION HISTORY')).toBeVisible()
await expect(page.getByText(foreignTitle)).toHaveCount(0) await expect(page.getByText(foreignTitle)).toHaveCount(0)
await expect(page.getByTestId(`history-card-${oldestId}`)).toHaveCount(0) await expect(page.getByTestId(`history-card-${oldestId}`)).toHaveCount(0)

View file

@ -1,6 +1,7 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { SITE_URLS } from '@d3ro/core/web-urls';
const SCREENSHOT_DIR = path.resolve('C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots'); const SCREENSHOT_DIR = path.resolve('C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots');
@ -21,9 +22,9 @@ test.describe.serial('Extreme Red Team - Cycle 4: Web Console & Public Surfaces'
}); });
}); });
test('RT-14: Web Public Hub - /login, /download, /releases & /accept-invite', async ({ page }) => { test('RT-14: Web Public Hub - /login and the /download redirect to the landing site', async ({ page }) => {
// 1. Test /login // 1. Test /login
await page.goto('/login'); await page.goto('/app/login');
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
await expect(page.getByText(/D3RO[- ]VOICE/i)).toBeVisible({ timeout: 10000 }); await expect(page.getByText(/D3RO[- ]VOICE/i)).toBeVisible({ timeout: 10000 });
@ -35,49 +36,10 @@ test.describe.serial('Extreme Red Team - Cycle 4: Web Console & Public Surfaces'
path: path.join(SCREENSHOT_DIR, 'rt14_01_web_login.png'), path: path.join(SCREENSHOT_DIR, 'rt14_01_web_login.png'),
}); });
// 2. Test /download // 2. /download lives only on the landing site; the web app just redirects there.
await page.goto('/download'); const downloadRes = await page.request.get('/app/download', { maxRedirects: 0 });
await page.waitForLoadState('domcontentloaded'); expect([307, 308]).toContain(downloadRes.status());
expect(downloadRes.headers()['location']).toBe(SITE_URLS.download);
await expect(page.getByText(/OFFICIAL STABLE RELEASE|D3RO Voice Desktop 1.1.0/i).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/Windows|macOS/i).first()).toBeVisible();
const primaryDownloadBtn = page.getByRole('link', { name: /Download for Windows/i });
await expect(primaryDownloadBtn).toBeVisible();
await expect(primaryDownloadBtn).toHaveAttribute('href', '/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
await expect(primaryDownloadBtn).toHaveAttribute('download', 'D3RO-Voice-Setup-1.1.0-x64.exe');
const cardDownloadBtn = page.getByRole('link', { name: /Download Setup \(\.exe\)/i });
await expect(cardDownloadBtn).toBeVisible();
await expect(cardDownloadBtn).toHaveAttribute('href', '/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
// Verify static release asset HTTP availability
const releaseHead = await page.request.head('/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
expect(releaseHead.status()).toBe(200);
expect(Number(releaseHead.headers()['content-length'])).toBeGreaterThan(100000000);
// Screenshot download
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt14_02_web_download.png'),
});
// 3. Test /releases
await page.goto('/releases');
await page.waitForLoadState('domcontentloaded');
await expect(page.getByText(/OFFICIAL STABLE RELEASE|D3RO Voice Desktop 1.1.0/i).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('link', { name: /Download for Windows/i })).toBeVisible();
// Screenshot releases
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt14_03_web_releases.png'),
});
// 4. Test /accept-invite
await page.goto('/accept-invite?token=red-team-bogus-token');
await page.waitForLoadState('domcontentloaded');
await expect(page.getByText(/TEAM INVITE|초대/i).first()).toBeVisible({ timeout: 10000 });
expect(uncaughtExceptions).toEqual([]); expect(uncaughtExceptions).toEqual([]);
}); });
@ -98,7 +60,7 @@ test.describe.serial('Extreme Red Team - Cycle 4: Web Console & Public Surfaces'
]; ];
for (const route of protectedRoutes) { for (const route of protectedRoutes) {
await page.goto(route); await page.goto(`/app${route}`);
await page.waitForURL(/\/login/, { timeout: 10000 }); await page.waitForURL(/\/login/, { timeout: 10000 });
expect(page.url()).toContain('/login'); expect(page.url()).toContain('/login');
} }

View file

@ -10,36 +10,31 @@ import { test, expect } from '@playwright/test'
test.describe('Smoke: unauthenticated access', () => { test.describe('Smoke: unauthenticated access', () => {
test('루트(/)는 /login으로 리다이렉트된다 (Supabase 미설정 시)', async ({ page }) => { test('루트(/)는 /login으로 리다이렉트된다 (Supabase 미설정 시)', async ({ page }) => {
await page.goto('/') await page.goto('/app')
await page.waitForURL(/\/login/, { timeout: 10000 }) await page.waitForURL(/\/login/, { timeout: 10000 })
expect(page.url()).toContain('/login') expect(page.url()).toContain('/login')
}) })
test('/login 페이지가 로드되고 D3RO VOICE 로고가 표시된다', async ({ page }) => { test('/login 페이지가 로드되고 D3RO VOICE 로고가 표시된다', async ({ page }) => {
await page.goto('/login') await page.goto('/app/login')
await expect(page.getByText(/D3RO[- ]VOICE/i)).toBeVisible({ timeout: 10000 }) await expect(page.getByText(/D3RO[- ]VOICE/i)).toBeVisible({ timeout: 10000 })
}) })
test('/login에 Google/GitHub OAuth 버튼이 보인다', async ({ page }) => { test('/login에 Google/GitHub OAuth 버튼이 보인다', async ({ page }) => {
await page.goto('/login') await page.goto('/app/login')
await expect(page.getByRole('button', { name: /Google/i })).toBeVisible() await expect(page.getByRole('button', { name: /Google/i })).toBeVisible()
await expect(page.getByRole('button', { name: /GitHub/i })).toBeVisible() await expect(page.getByRole('button', { name: /GitHub/i })).toBeVisible()
}) })
test('/dashboard는 미로그인 시 /login으로 리다이렉트', async ({ page }) => { test('/dashboard는 미로그인 시 /login으로 리다이렉트', async ({ page }) => {
await page.goto('/dashboard') await page.goto('/app/dashboard')
await page.waitForURL(/\/login/, { timeout: 10000 }) await page.waitForURL(/\/login/, { timeout: 10000 })
expect(page.url()).toContain('/login') expect(page.url()).toContain('/login')
}) })
test('/meetings도 미로그인 시 /login으로 리다이렉트', async ({ page }) => { test('/meetings도 미로그인 시 /login으로 리다이렉트', async ({ page }) => {
await page.goto('/meetings') await page.goto('/app/meetings')
await page.waitForURL(/\/login/, { timeout: 10000 }) await page.waitForURL(/\/login/, { timeout: 10000 })
expect(page.url()).toContain('/login') expect(page.url()).toContain('/login')
}) })
test('/accept-invite?token=bogus는 페이지 로드 (에러 메시지 표시)', async ({ page }) => {
await page.goto('/accept-invite?token=bogus')
await expect(page.getByText(/TEAM INVITE/i)).toBeVisible({ timeout: 10000 })
})
}) })

View file

@ -1,12 +0,0 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.d3ro.voice",
"sha256_cert_fingerprints": [
"01:00:19:21:DB:4F:33:40:85:CE:21:E4:B8:DE:CC:BD:71:DA:87:67:C5:6E:3B:59:83:2A:A1:C8:29:EA:0D:AB"
]
}
}
]

View file

@ -1,17 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="0; url=/download" />
<link rel="canonical" href="https://d3ro.chanpaca.net/download" />
<title>D3RO Voice 릴리스 준비</title>
</head>
<body>
<main>
<h1>D3RO Voice 릴리스 준비</h1>
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
<p><a href="/download">공식 릴리스 준비 페이지로 이동</a></p>
</main>
</body>
</html>

View file

@ -1,231 +0,0 @@
x64:
firstOrDefaultFilePatterns:
- '!**/node_modules/**'
- '!build{,/**/*}'
- '!release/1.0.0{,/**/*}'
- out/**/*
- '!out/**/*.map'
- package.json
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
- '!**/._*'
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
- '!.yarn{,/**/*}'
- '!.editorconfig'
- '!.yarnrc.yml'
nodeModuleFilePatterns:
- '**/*'
- out/**/*
- '!out/**/*.map'
nsis:
script: |-
!include "D:\workspace\D3ROVoice\node_modules\app-builder-lib\templates\nsis\include\StdUtils.nsh"
!addincludedir "D:\workspace\D3ROVoice\node_modules\app-builder-lib\templates\nsis\include"
!macro _isUpdated _a _b _t _f
${StdUtils.TestParameter} $R9 "updated"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isUpdated `"" isUpdated ""`
!macro _isForceRun _a _b _t _f
${StdUtils.TestParameter} $R9 "force-run"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isForceRun `"" isForceRun ""`
!macro _isKeepShortcuts _a _b _t _f
${StdUtils.TestParameter} $R9 "keep-shortcuts"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isKeepShortcuts `"" isKeepShortcuts ""`
!macro _isNoDesktopShortcut _a _b _t _f
${StdUtils.TestParameter} $R9 "no-desktop-shortcut"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isNoDesktopShortcut `"" isNoDesktopShortcut ""`
!macro _isDeleteAppData _a _b _t _f
${StdUtils.TestParameter} $R9 "delete-app-data"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isDeleteAppData `"" isDeleteAppData ""`
!macro _isForAllUsers _a _b _t _f
${StdUtils.TestParameter} $R9 "allusers"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isForAllUsers `"" isForAllUsers ""`
!macro _isForCurrentUser _a _b _t _f
${StdUtils.TestParameter} $R9 "currentuser"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isForCurrentUser `"" isForCurrentUser ""`
!macro addLangs
!insertmacro MUI_LANGUAGE "English"
!insertmacro MUI_LANGUAGE "German"
!insertmacro MUI_LANGUAGE "French"
!insertmacro MUI_LANGUAGE "SpanishInternational"
!insertmacro MUI_LANGUAGE "SimpChinese"
!insertmacro MUI_LANGUAGE "TradChinese"
!insertmacro MUI_LANGUAGE "Japanese"
!insertmacro MUI_LANGUAGE "Korean"
!insertmacro MUI_LANGUAGE "Italian"
!insertmacro MUI_LANGUAGE "Dutch"
!insertmacro MUI_LANGUAGE "Danish"
!insertmacro MUI_LANGUAGE "Swedish"
!insertmacro MUI_LANGUAGE "Norwegian"
!insertmacro MUI_LANGUAGE "Finnish"
!insertmacro MUI_LANGUAGE "Russian"
!insertmacro MUI_LANGUAGE "Portuguese"
!insertmacro MUI_LANGUAGE "PortugueseBR"
!insertmacro MUI_LANGUAGE "Polish"
!insertmacro MUI_LANGUAGE "Ukrainian"
!insertmacro MUI_LANGUAGE "Czech"
!insertmacro MUI_LANGUAGE "Slovak"
!insertmacro MUI_LANGUAGE "Hungarian"
!insertmacro MUI_LANGUAGE "Arabic"
!insertmacro MUI_LANGUAGE "Turkish"
!insertmacro MUI_LANGUAGE "Thai"
!insertmacro MUI_LANGUAGE "Vietnamese"
!macroend
!addincludedir "D:\workspace\D3ROVoice\apps\desktop\build"
!include "D:\workspace\D3ROVoice\apps\desktop\build\installer.nsh"
!include "C:\Users\encep\AppData\Local\Temp\t-1IM43R\2-messages.nsh"
!addplugindir /x86-unicode "C:\Users\encep\AppData\Local\electron-builder\Cache\nsis\nsis-resources-3.4.1-nsis-resources-3.4.1\plugins\x86-unicode"
Var newStartMenuLink
Var oldStartMenuLink
Var newDesktopLink
Var oldDesktopLink
Var oldShortcutName
Var oldMenuDirectory
!include "common.nsh"
!include "MUI2.nsh"
!include "multiUser.nsh"
!include "allowOnlyOneInstallerInstance.nsh"
!ifdef BUILD_UNINSTALLER
!ifmacrodef customUnInstallSection
!define MUI_COMPONENTSPAGE_NODESC
!insertmacro MUI_UNPAGE_COMPONENTS
!endif
!endif
!ifdef INSTALL_MODE_PER_ALL_USERS
!ifdef BUILD_UNINSTALLER
RequestExecutionLevel user
!else
RequestExecutionLevel admin
!endif
!else
RequestExecutionLevel user
!endif
!ifdef BUILD_UNINSTALLER
SilentInstall silent
!else
Var appExe
Var launchLink
!endif
!ifdef ONE_CLICK
!include "oneClick.nsh"
!else
!include "assistedInstaller.nsh"
!endif
!insertmacro addLangs
!ifmacrodef customHeader
!insertmacro customHeader
!endif
Function .onInit
Call setInstallSectionSpaceRequired
SetOutPath $INSTDIR
${LogSet} on
!ifmacrodef preInit
!insertmacro preInit
!endif
!ifdef DISPLAY_LANG_SELECTOR
!insertmacro MUI_LANGDLL_DISPLAY
!endif
!ifdef BUILD_UNINSTALLER
WriteUninstaller "${UNINSTALLER_OUT_FILE}"
!insertmacro quitSuccess
!else
!insertmacro check64BitAndSetRegView
!ifdef ONE_CLICK
!insertmacro ALLOW_ONLY_ONE_INSTALLER_INSTANCE
!else
${IfNot} ${UAC_IsInnerInstance}
!insertmacro ALLOW_ONLY_ONE_INSTALLER_INSTANCE
${EndIf}
!endif
!insertmacro initMultiUser
!ifmacrodef customInit
!insertmacro customInit
!endif
!ifmacrodef addLicenseFiles
InitPluginsDir
!insertmacro addLicenseFiles
!endif
!endif
FunctionEnd
!ifndef BUILD_UNINSTALLER
!include "installUtil.nsh"
!endif
Section "install" INSTALL_SECTION_ID
!ifndef BUILD_UNINSTALLER
# If we're running a silent upgrade of a per-machine installation, elevate so extracting the new app will succeed.
# For a non-silent install, the elevation will be triggered when the install mode is selected in the UI,
# but that won't be executed when silent.
!ifndef INSTALL_MODE_PER_ALL_USERS
!ifndef ONE_CLICK
${if} $hasPerMachineInstallation == "1" # set in onInit by initMultiUser
${andIf} ${Silent}
${ifNot} ${UAC_IsAdmin}
ShowWindow $HWNDPARENT ${SW_HIDE}
!insertmacro UAC_RunElevated
${Switch} $0
${Case} 0
${Break}
${Case} 1223 ;user aborted
${Break}
${Default}
MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "Unable to elevate, error $0"
${Break}
${EndSwitch}
Quit
${else}
!insertmacro setInstallModePerAllUsers
${endIf}
${endIf}
!endif
!endif
!include "installSection.nsh"
!endif
SectionEnd
Function setInstallSectionSpaceRequired
!insertmacro setSpaceRequired ${INSTALL_SECTION_ID}
FunctionEnd
!ifdef BUILD_UNINSTALLER
!include "uninstaller.nsh"
!endif

View file

@ -1,8 +0,0 @@
version: 1.0.0
files:
- url: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
size: 102123822
path: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
releaseDate: '2026-08-20T01:56:59.299Z'

View file

@ -1,183 +0,0 @@
'use client'
// apps/web/src/app/accept-invite/page.tsx
// 팀 초대 수락 페이지 — URL의 ?token=을 team-accept Edge Function으로 전달
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Box, Stack, Alert, CircularProgress } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette } from '@d3ro/ui/theme'
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
type AcceptState = 'loading' | 'success' | 'error' | 'need_login'
// useSearchParams는 Suspense boundary 필요 (Next.js 15 prerender 규칙)
export default function AcceptInvitePage(): React.ReactElement {
return (
<Suspense
fallback={
<Box
sx={{
minHeight: '100dvh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: d3roPalette.bg.app
}}
>
<CircularProgress color="warning" />
</Box>
}
>
<AcceptInviteInner />
</Suspense>
)
}
function AcceptInviteInner(): React.ReactElement {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [state, setState] = useState<AcceptState>('loading')
const [message, setMessage] = useState<string>('')
const [teamId, setTeamId] = useState<string | null>(null)
useEffect(() => {
async function run(): Promise<void> {
if (!token) {
setState('error')
setMessage('유효하지 않은 초대 링크입니다 (토큰 누락).')
return
}
if (!isSupabaseConfigured()) {
setState('error')
setMessage('Supabase가 설정되지 않았습니다.')
return
}
const supabase = getSupabaseBrowserClient()
const {
data: { session }
} = await supabase.auth.getSession()
if (!session) {
setState('need_login')
setMessage('초대를 수락하려면 먼저 로그인해주세요.')
// 토큰을 sessionStorage에 저장해두고 로그인 후 돌아오도록
try {
sessionStorage.setItem('pending_invite_token', token)
} catch {
// storage 차단 시 무시
}
return
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/team-accept`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ token })
}
)
if (!response.ok) {
const errData = (await response.json()) as { error?: string; message?: string }
setState('error')
setMessage(errData.message ?? errData.error ?? `실패: ${response.status}`)
return
}
const data = (await response.json()) as { team_id: string; role: string }
setTeamId(data.team_id)
setState('success')
setMessage(`팀에 가입되었습니다 (${data.role}). 잠시 후 이동합니다...`)
// 성공 시 3초 후 팀 페이지로
setTimeout(() => {
router.replace(`/teams/${data.team_id}`)
}, 2000)
} catch (e) {
setState('error')
setMessage(e instanceof Error ? e.message : 'Unknown error')
}
}
void run()
}, [token, router])
return (
<Box
sx={{
minHeight: '100dvh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: d3roPalette.bg.app,
p: 4
}}
>
<MetalCard sx={{ maxWidth: 480, width: '100%', p: 4 }}>
<Stack spacing={3} alignItems="center">
<PhosphorText variant="title">TEAM INVITE</PhosphorText>
{state === 'loading' && <CircularProgress color="warning" />}
{state === 'success' && (
<Alert severity="success" variant="outlined" sx={{ width: '100%' }}>
{message}
</Alert>
)}
{state === 'error' && (
<>
<Alert severity="error" variant="outlined" sx={{ width: '100%' }}>
{message}
</Alert>
<Box
component="a"
href="/dashboard"
sx={{
color: d3roPalette.accent.main,
textDecoration: 'none',
fontSize: 13
}}
>
대시보드로 이동
</Box>
</>
)}
{state === 'need_login' && (
<>
<Alert severity="info" variant="outlined" sx={{ width: '100%' }}>
{message}
</Alert>
<Box
component="a"
href="/login"
sx={{
color: d3roPalette.accent.main,
textDecoration: 'none',
fontSize: 13
}}
>
로그인 페이지로 이동
</Box>
</>
)}
{teamId && (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 11 }}>
Team ID: {teamId}
</Box>
)}
</Stack>
</MetalCard>
</Box>
)
}

View file

@ -1,650 +0,0 @@
// apps/web/src/app/download/page.tsx
// D3RO Voice Web App — Official Download Center & Release History
'use client'
import React, { useState } from 'react'
import {
Box,
Typography,
Button,
Container,
Paper,
Chip,
} from '@mui/material'
import DownloadIcon from '@mui/icons-material/Download'
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'
import AppleIcon from '@mui/icons-material/Apple'
import WindowsIcon from '@mui/icons-material/Window'
import StorageIcon from '@mui/icons-material/Storage'
import CloudUploadIcon from '@mui/icons-material/CloudUpload'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import { d3roPalette } from '@d3ro/ui/theme'
import {
DESKTOP_FEED_URL,
DESKTOP_RELEASE_HUB_URL,
DESKTOP_RELEASES_URL,
DESKTOP_RELEASE_DATE,
DESKTOP_VERSION,
DESKTOP_WINDOWS_INSTALLER_FILENAME,
DESKTOP_WINDOWS_INSTALLER_URL,
} from '@/lib/desktop-release'
export default function DownloadPage(): React.ReactElement {
const [verifyStatus, setVerifyStatus] = useState<'idle' | 'computing' | 'done'>('idle')
const [computedHash, setComputedHash] = useState('')
const [fileName, setFileName] = useState('')
const handleFileVerify = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = e.target.files
if (!files || files.length === 0) return
const file = files[0]
setFileName(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)`)
setVerifyStatus('computing')
const arrayBuffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
setComputedHash(hashHex)
setVerifyStatus('done')
}
return (
<Box
sx={{
minHeight: '100dvh',
bgcolor: d3roPalette.bg.app,
color: d3roPalette.text.primary,
backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, var(--d3-tag-cyan), transparent 70%)',
py: { xs: 4, md: 8 },
px: 2,
}}
>
<Container maxWidth="lg">
{/* Navigation Bar */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: '10px',
bgcolor: d3roPalette.accent.dark,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
color: 'var(--d3-text-inverse)',
boxShadow: '0 0 20px var(--d3-tag-cyan)',
}}
>
D3
</Box>
<Typography variant="h6" sx={{ fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--d3-text-inverse)' }}>
D3RO VOICE
</Typography>
<Chip
label={`v${DESKTOP_VERSION} OFFICIAL STABLE RELEASE`}
size="small"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 15%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid var(--d3-status-success)',
fontWeight: 500,
fontSize: '10px',
}}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
href={DESKTOP_RELEASES_URL}
target="_blank"
endIcon={<OpenInNewIcon sx={{ fontSize: '14px !important' }} />}
sx={{ color: d3roPalette.text.secondary, fontSize: '13px', textTransform: 'none', '&:hover': { color: 'var(--d3-text-inverse)' } }}
>
Forgejo Releases
</Button>
<Button
href="/login"
variant="outlined"
sx={{
borderColor: 'var(--d3-glass-hairlineStrong)',
color: d3roPalette.text.primary,
textTransform: 'none',
borderRadius: '10px',
'&:hover': { borderColor: d3roPalette.accent.light, bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 8%, transparent)' },
}}
>
Web Console
</Button>
</Box>
</Box>
{/* Hero Section */}
<Box sx={{ textAlign: 'center', maxWidth: 700, mx: 'auto', mb: 8 }}>
<Chip
icon={<ShieldOutlinedIcon sx={{ fontSize: '14px !important', color: 'var(--d3-tag-green) !important' }} />}
label="OFFICIAL STABLE RELEASE"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 10%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid var(--d3-status-success)',
fontWeight: 500,
fontSize: '11px',
mb: 3,
}}
/>
<Typography
variant="h3"
component="h1"
sx={{
fontWeight: 600,
letterSpacing: '-0.03em',
mb: 2,
fontSize: { xs: '2rem', md: '3rem' },
}}
>
Download{' '}
<Box component="span" sx={{ color: d3roPalette.accent.light }}>
D3RO Voice
</Box>{' '}
Desktop {DESKTOP_VERSION}
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '16px', lineHeight: 1.6 }}>
Official multi-platform release. Zero-latency offline Whisper Large-v3-Turbo,
cloud AI failover, local knowledge base, and update-feed verified integrity.
</Typography>
</Box>
{/* Primary Download Card */}
<Paper
elevation={0}
sx={{
maxWidth: 580,
mx: 'auto',
p: { xs: 3, sm: 4 },
borderRadius: '24px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 75%, transparent)',
backdropFilter: 'blur(20px)',
border: '1px solid var(--d3-tag-cyan)',
boxShadow: '0 24px 60px -15px var(--d3-scrim), 0 0 40px -10px var(--d3-tag-cyan)',
mb: 10,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
width: 48,
height: 48,
borderRadius: '14px',
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)',
border: '1px solid var(--d3-tag-cyan)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: d3roPalette.accent.light,
}}
>
<ShieldOutlinedIcon />
</Box>
<Box>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)' }}>
D3RO Voice Desktop {DESKTOP_VERSION}
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '12px', fontFamily: 'monospace' }}>
Windows 10 / 11 (x64) · NSIS standalone installer
</Typography>
</Box>
</Box>
<Chip
label="VERIFIED & ACTIVE"
size="small"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 15%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid var(--d3-status-success)',
fontWeight: 600,
fontSize: '10px',
}}
/>
</Box>
<Button
component="a"
href={DESKTOP_WINDOWS_INSTALLER_URL}
variant="contained"
fullWidth
size="large"
startIcon={<DownloadIcon />}
sx={{
py: 2,
borderRadius: '14px',
bgcolor: d3roPalette.accent.light,
color: d3roPalette.bg.card,
fontWeight: 600,
fontSize: '15px',
textTransform: 'none',
boxShadow: '0 8px 25px var(--d3-tag-cyan)',
'&:hover': { bgcolor: d3roPalette.accent.main },
}}
>
Download for Windows (x64) - v{DESKTOP_VERSION}
</Button>
<Box
sx={{
mt: 3,
pt: 2.5,
borderTop: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' },
justifyContent: 'space-between',
gap: 1.5,
fontSize: '12px',
color: d3roPalette.text.secondary,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>SHA-512:</span>
<Button
href={`${DESKTOP_FEED_URL}/latest.yml`}
target="_blank"
size="small"
sx={{
color: d3roPalette.text.primary,
fontFamily: 'monospace',
fontSize: '11px',
p: 0,
minWidth: 0,
textTransform: 'none',
}}
>
latest.yml
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
href={DESKTOP_RELEASE_HUB_URL}
target="_blank"
size="small"
sx={{
color: d3roPalette.accent.light,
fontSize: '11px',
p: 0,
minWidth: 0,
textTransform: 'none',
}}
>
Release notes
</Button>
<Typography sx={{ color: d3roPalette.tag.green, fontSize: '11px', fontWeight: 600 }}>
✓ Update feed connected
</Typography>
</Box>
</Box>
</Paper>
{/* All Platform Bento Grid */}
<Box sx={{ mb: 10 }}>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 1, color: 'var(--d3-text-inverse)' }}>
All Platform Packages
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '14px', mb: 4 }}>
Planned targets and their current verification status.
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 3 }}>
{/* Windows Card */}
<Paper
elevation={0}
sx={{
p: 3.5,
borderRadius: '20px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
transition: 'border-color 0.2s',
'&:hover': { borderColor: 'color-mix(in srgb, var(--d3-accent-light) 40%, transparent)' },
}}
>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<WindowsIcon sx={{ color: d3roPalette.accent.light, fontSize: 28 }} />
<Chip label="x64 TARGET" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', mb: 1 }}>Windows</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Official stable installer with automated background updates and zero-latency local AI.
</Typography>
</Box>
<Button
component="a"
href={DESKTOP_WINDOWS_INSTALLER_URL}
variant="outlined"
fullWidth
startIcon={<DownloadIcon />}
sx={{
borderColor: 'color-mix(in srgb, var(--d3-accent-light) 30%, transparent)',
color: d3roPalette.accent.light,
textTransform: 'none',
borderRadius: '10px',
fontWeight: 500,
'&:hover': { bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 10%, transparent)', borderColor: d3roPalette.accent.light },
}}
>
Download Setup (.exe)
</Button>
</Paper>
{/* macOS Card */}
<Paper
elevation={0}
sx={{
p: 3.5,
borderRadius: '20px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
transition: 'border-color 0.2s',
'&:hover': { borderColor: 'color-mix(in srgb, var(--d3-tag-purple) 40%, transparent)' },
}}
>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<AppleIcon sx={{ color: d3roPalette.tag.purple, fontSize: 28 }} />
<Chip label="M1 / M2 / M3 / M4" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', mb: 1 }}>macOS</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Apple Silicon candidate undergoing code-signing and installation verification.
</Typography>
</Box>
<Button
disabled
variant="outlined"
fullWidth
startIcon={<DownloadIcon />}
sx={{
borderColor: 'color-mix(in srgb, var(--d3-tag-purple) 30%, transparent)',
color: d3roPalette.tag.purple,
textTransform: 'none',
borderRadius: '10px',
fontWeight: 500,
'&:hover': { bgcolor: 'color-mix(in srgb, var(--d3-tag-purple) 10%, transparent)', borderColor: d3roPalette.tag.purple },
}}
>
Verification pending
</Button>
</Paper>
{/* Synology NAS & Docker Card */}
<Paper
elevation={0}
sx={{
p: 3.5,
borderRadius: '20px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
transition: 'border-color 0.2s',
'&:hover': { borderColor: 'color-mix(in srgb, var(--d3-tag-green) 40%, transparent)' },
}}
>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<StorageIcon sx={{ color: d3roPalette.tag.green, fontSize: 28 }} />
<Chip label="Container Manager" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', mb: 1 }}>Synology NAS</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Self-hosted private deployment package for Synology Container Manager & CRM.
</Typography>
</Box>
<Button
href="https://git.chanpaca.net/yunchan/d3ro-voice/src/branch/main/docker-compose.nas.yml"
target="_blank"
variant="outlined"
fullWidth
startIcon={<OpenInNewIcon />}
sx={{
borderColor: 'color-mix(in srgb, var(--d3-tag-green) 30%, transparent)',
color: d3roPalette.tag.green,
textTransform: 'none',
borderRadius: '10px',
fontWeight: 500,
'&:hover': { bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 10%, transparent)', borderColor: d3roPalette.tag.green },
}}
>
docker-compose.nas.yml →
</Button>
</Paper>
</Box>
</Box>
{/* Client-Side Cryptographic Verifier */}
<Paper
elevation={0}
sx={{
p: { xs: 3, md: 5 },
borderRadius: '24px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
border: '1px solid var(--d3-tag-cyan)',
mb: 10,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2, mb: 3 }}>
<Box>
<Typography sx={{ color: d3roPalette.accent.light, fontSize: '11px', fontFamily: 'monospace', fontWeight: 500, mb: 0.5 }}>
LOCAL FILE UTILITY
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, color: 'var(--d3-text-inverse)' }}>
SHA-256 File Calculator
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px' }}>
Compute a selected file hash locally. This does not certify an official D3RO Voice release.
</Typography>
</Box>
<Button
component="label"
variant="outlined"
startIcon={<CloudUploadIcon />}
sx={{
borderColor: 'color-mix(in srgb, var(--d3-accent-light) 30%, transparent)',
color: d3roPalette.accent.light,
borderRadius: '12px',
textTransform: 'none',
fontWeight: 600,
}}
>
Select File to Check
<input type="file" hidden onChange={handleFileVerify} />
</Button>
</Box>
{verifyStatus !== 'idle' && (
<Box
sx={{
p: 2.5,
borderRadius: '14px',
bgcolor: 'var(--d3-scrim)',
border: '1px solid var(--d3-overlay-strong)',
fontFamily: 'monospace',
fontSize: '12px',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<span style={{ color: d3roPalette.text.primary, fontWeight: 600 }}>{fileName}</span>
{verifyStatus === 'computing' && <Chip label="Computing..." size="small" sx={{ bgcolor: d3roPalette.tag.orange, color: 'var(--d3-bg-app)' }} />}
{verifyStatus === 'done' && <Chip label="LOCAL HASH GENERATED" size="small" sx={{ bgcolor: d3roPalette.accent.light, color: 'var(--d3-bg-app)' }} />}
</Box>
<Box sx={{ color: d3roPalette.text.secondary }}>
Calculated: <span style={{ color: d3roPalette.accent.light }}>{computedHash || 'Hashing...'}</span>
</Box>
</Box>
)}
</Paper>
{/* Release Changelog Timeline */}
<Box sx={{ mb: 10 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600, color: 'var(--d3-text-inverse)' }}>
Release Changelog & History
</Typography>
<Button
href={DESKTOP_RELEASES_URL}
target="_blank"
endIcon={<OpenInNewIcon sx={{ fontSize: '14px !important' }} />}
sx={{ color: d3roPalette.accent.light, fontSize: '13px', textTransform: 'none' }}
>
Forgejo Releases
</Button>
</Box>
{/* Latest release item */}
<Paper
elevation={0}
sx={{
p: 3.5,
borderRadius: '16px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
borderLeft: '4px solid var(--d3-tag-green)',
border: '1px solid var(--d3-overlay-strong)',
borderLeftWidth: '4px',
mb: 3,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', fontFamily: 'monospace' }}>
v{DESKTOP_VERSION}
</Typography>
<Chip
label="LATEST STABLE · OFFICIALLY VERIFIED"
size="small"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 15%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid var(--d3-status-success)',
fontWeight: 500,
}}
/>
<Typography sx={{ color: d3roPalette.text.label, fontSize: '12px', fontFamily: 'monospace' }}>{DESKTOP_RELEASE_DATE}</Typography>
</Box>
<Button
component="a"
href={DESKTOP_WINDOWS_INSTALLER_URL}
size="small"
startIcon={<DownloadIcon />}
sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontWeight: 500 }}
>
Download v{DESKTOP_VERSION} (.exe)
</Button>
</Box>
<Typography sx={{ color: d3roPalette.text.primary, fontSize: '13px', lineHeight: 1.6, mb: 2 }}>
• <strong>Multi-Platform Cross-Device Architecture</strong>: Synchronized ecosystem spanning Electron desktop, Next.js cloud console, and React Native mobile.<br />
• <strong>Canonical Forgejo Auto-Update & Policy SSOT</strong>: Fully automated, cryptographic release updates with delta installer support and remote kill switches.<br />
• <strong>Enterprise Red-Team Hardened Voice Engine</strong>: 18/18 headless & headful integration scenarios verified with 100% fail-closed auth security.<br />
• <strong>Offline-First Privacy Intelligence</strong>: Local Whisper Large-v3-Turbo with zero-latency push-to-talk transcription.
</Typography>
<Box
sx={{
p: 1.5,
borderRadius: '8px',
bgcolor: 'var(--d3-scrim)',
fontFamily: 'monospace',
fontSize: '11px',
color: d3roPalette.text.secondary,
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
gap: 1,
}}
>
<a href={`${DESKTOP_FEED_URL}/latest.yml`} target="_blank" rel="noreferrer">
{DESKTOP_WINDOWS_INSTALLER_FILENAME} · latest.yml
</a>
<span>Windows x64 · NSIS installer</span>
</Box>
</Paper>
{/* v1.0.0 Release Item */}
<Paper
elevation={0}
sx={{
p: 3.5,
borderRadius: '16px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
borderLeft: '4px solid var(--d3-accent-light)',
border: '1px solid var(--d3-overlay-strong)',
borderLeftWidth: '4px',
mb: 3,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', fontFamily: 'monospace' }}>
v1.0.0
</Typography>
<Chip
label="ARCHIVED · DOWNLOAD UNAVAILABLE"
size="small"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)',
color: d3roPalette.accent.light,
border: '1px solid var(--d3-tag-cyan)',
fontWeight: 500,
}}
/>
<Typography sx={{ color: d3roPalette.text.label, fontSize: '12px', fontFamily: 'monospace' }}>2026-08-20</Typography>
</Box>
<Button
disabled
size="small"
startIcon={<DownloadIcon />}
sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontWeight: 500 }}
>
Historical binary unavailable
</Button>
</Box>
<Typography sx={{ color: d3roPalette.text.primary, fontSize: '13px', lineHeight: 1.6, mb: 2 }}>
• <strong>10+ Ad Mediation Engine</strong>: Real-time header bidding auction (EthicalAds, Carbon, GAM, Playwire, AppLovin, Unity).<br />
• <strong>Rewarded Video Token Refills</strong>: Watch 15s sponsored video to gain +50 Cloud AI tokens.<br />
• <strong>Forgejo CI/CD & Synology NAS Packaging</strong>: Multi-platform automated packaging and Docker CRM.<br />
• <strong>100% Local Whisper Large-v3-Turbo</strong>: Zero-latency offline speech transcription.
</Typography>
<Box
sx={{
p: 1.5,
borderRadius: '8px',
bgcolor: 'var(--d3-scrim)',
fontFamily: 'monospace',
fontSize: '11px',
color: d3roPalette.text.secondary,
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>This entry is retained only as history. No installer, fixed hash, or size is offered as a current release.</span>
</Box>
</Paper>
</Box>
</Container>
</Box>
)
}

View file

@ -1,4 +0,0 @@
// apps/web/src/app/releases/page.tsx
import DownloadPage from '../download/page'
export default DownloadPage

View file

@ -1,27 +0,0 @@
// apps/web/src/lib/desktop-release.ts
// 데스크톱 공식 릴리스 계약 SSOT.
//
// 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는
// 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다.
export const DESKTOP_VERSION = '1.6.0'
/** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */
export const DESKTOP_RELEASE_DATE = '2026-09-24'
const FORGEJO_ORIGIN = 'https://git.chanpaca.net'
const FORGEJO_OWNER = 'yunchan'
const FORGEJO_REPO = 'd3ro-voice'
/** Registry 안에서 항상 최신 설치 자산을 가리키는 feed 루트 (updater와 동일). */
export const DESKTOP_FEED_URL = `${FORGEJO_ORIGIN}/api/packages/${FORGEJO_OWNER}/generic/${FORGEJO_REPO}/latest`
export const DESKTOP_WINDOWS_INSTALLER_FILENAME = `D3RO-Voice-Setup-${DESKTOP_VERSION}-x64.exe`
export const DESKTOP_WINDOWS_INSTALLER_URL = `${DESKTOP_FEED_URL}/${DESKTOP_WINDOWS_INSTALLER_FILENAME}`
/** Forgejo Release 허브 (릴리스 노트 + 자산 첨부). */
export const DESKTOP_RELEASE_HUB_URL = `${FORGEJO_ORIGIN}/${FORGEJO_OWNER}/${FORGEJO_REPO}/releases/tag/v${DESKTOP_VERSION}`
/** Release 자산 목록 (버전 아카이브). */
export const DESKTOP_RELEASES_URL = `${FORGEJO_ORIGIN}/${FORGEJO_OWNER}/${FORGEJO_REPO}/releases`

View file

@ -1,61 +0,0 @@
// scripts/capture-download-and-release-hub.js
const { chromium } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const http = require('http');
const OUTPUT_DIR = 'C:/Users/encep/.gemini/antigravity-cli/brain/3ab7ae82-5634-41d5-93a7-3c12c22503e2/screenshots/download_center';
function serveStatic(dir) {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
let reqPath = req.url.split('?')[0];
if (reqPath === '/' || reqPath === '') reqPath = '/download.html';
const filePath = path.join(dir, reqPath);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath).toLowerCase();
const mime = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' };
res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream' });
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
async function capture() {
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const sitePublicDir = path.resolve(__dirname, '../site/public');
const { server, port } = await serveStatic(sitePublicDir);
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
console.log(`Navigating to http://127.0.0.1:${port}/download.html...`);
await page.goto(`http://127.0.0.1:${port}/download.html`, { waitUntil: 'networkidle' });
await page.waitForTimeout(1500);
const shot1 = path.join(OUTPUT_DIR, '01_download_center_hero_desktop.png');
await page.screenshot({ path: shot1, fullPage: false });
console.log('Saved:', shot1);
const shotFull = path.join(OUTPUT_DIR, '02_download_center_full_page.png');
await page.screenshot({ path: shotFull, fullPage: true });
console.log('Saved:', shotFull);
await browser.close();
server.close();
}
capture().catch(console.error);

View file

@ -1,92 +0,0 @@
// scripts/ci/sync-and-publish-forgejo-release.mjs
// Desktop-only legacy sync. Android production artifacts are intentionally
// excluded: they may be published only by .github/workflows/release.yml after
// signed release evidence is verified in an isolated create-only directory.
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import path from 'node:path';
import credentialHelpers from '../lib/credentials.cjs';
const { forgejoAuthorization } = credentialHelpers;
const RELEASE_DIR = 'apps/desktop/release/1.0.0';
const SITE_PUBLIC_RELEASES = 'site/public/releases/1.0.0';
const SITE_PUBLIC_ROOT = 'site/public/releases';
const SITE_DIST_RELEASES = 'site/dist/releases/1.0.0';
const SITE_DIST_ROOT = 'site/dist/releases';
console.log('--- 1. Syncing binary files to site static distribution directories ---');
mkdirSync(SITE_PUBLIC_RELEASES, { recursive: true });
mkdirSync(SITE_DIST_RELEASES, { recursive: true });
const filesToSync = [
'D3RO-Voice-Setup-1.0.0-x64.exe',
'D3RO-Voice-Setup-1.0.0-x64.exe.blockmap',
'latest.yml'
];
for (const file of filesToSync) {
const src = path.join(RELEASE_DIR, file);
if (existsSync(src)) {
// Copy to releases/1.0.0/
copyFileSync(src, path.join(SITE_PUBLIC_RELEASES, file));
copyFileSync(src, path.join(SITE_DIST_RELEASES, file));
// Also copy latest.yml to releases/ root
if (file === 'latest.yml') {
copyFileSync(src, path.join(SITE_PUBLIC_ROOT, file));
copyFileSync(src, path.join(SITE_DIST_ROOT, file));
}
console.log(`✓ Copied ${file} to public distribution paths`);
} else {
console.warn(`! Source file not found: ${src}`);
}
}
console.log('\n--- 2. Publishing Official Release v1.0.0 on Forgejo git.chanpaca.net ---');
const authorization = forgejoAuthorization();
const releasePayload = {
tag_name: 'v1.0.0',
target_commitish: 'main',
name: 'D3RO Voice AI Production Release v1.0.0',
body: `## D3RO Voice AI v1.0.0 Official Release
### 🚀 Major Highlights
- **10+ Global Ad Mediation Engine**: Header bidding waterfall with EthicalAds, Carbon Ads, Google Ad Manager, Playwire, AppLovin, and Unity Ads.
- **Free Tier Rewarded Token Refills**: Watch 15s sponsored video to gain +50 Cloud AI tokens.
- **100% Local Whisper Large-v3-Turbo**: Complete offline speech-to-text transcription with hardware acceleration.
### 📦 Binary Checksums (SHA-256)
- \`D3RO-Voice-Setup-1.0.0-x64.exe\`: \`b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2\` (102 MB)
- \`D3RO-Voice-Setup-1.0.0-x64.exe.blockmap\`: \`795b7230bec047284785f480026d51b9247d8618e083d88cfda786d1894ca367\`
`,
draft: false,
prerelease: false
};
async function publishRelease() {
try {
const res = await fetch('https://git.chanpaca.net/api/v1/repos/yunchan/d3ro-voice/releases', {
method: 'POST',
headers: {
'Authorization': authorization,
'Content-Type': 'application/json'
},
body: JSON.stringify(releasePayload)
});
console.log('Forgejo Release Create HTTP Status:', res.status);
const data = await res.json();
if (res.status === 201 || res.status === 200) {
console.log('🎉 Forgejo Release v1.0.0 created successfully:', data.html_url);
} else {
console.log('Response:', data);
}
} catch (err) {
console.error('Failed to create release on Forgejo:', err);
}
}
publishRelease();

View file

@ -180,13 +180,10 @@ updateText('apps/web/src/components/layout/sidebar.tsx', (text) =>
), ),
) )
// Download centers build the installer filename from these constants. They drifted // The download section (site/ only) builds the installer filename from these
// to 1.2.0 while the feed served newer versions, so the download button pointed at // constants. They once drifted to 1.2.0 while the feed served newer versions, so the
// an installer that does not exist. Keep both surfaces on the version SSOT. // download button pointed at an installer that does not exist. Keep it on the SSOT.
for (const releaseContractPath of [ for (const releaseContractPath of ['site/src/release.ts']) {
'apps/web/src/lib/desktop-release.ts',
'site/src/release.ts',
]) {
updateText(releaseContractPath, (text) => updateText(releaseContractPath, (text) =>
replaceExactlyOnce( replaceExactlyOnce(
replaceExactlyOnce( replaceExactlyOnce(

View file

@ -1,44 +0,0 @@
// scripts/ci/upload-asset-to-forgejo-release.mjs
// Uploads D3RO-Voice-Setup-1.0.0-x64.exe directly to Forgejo Release v1.0.0
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import credentialHelpers from '../lib/credentials.cjs';
const { forgejoAuthorization } = credentialHelpers;
const authorization = forgejoAuthorization();
const EXE_PATH = 'apps/desktop/release/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe';
async function uploadAsset() {
console.log('--- Fetching release ID for v1.0.0 ---');
const relRes = await fetch('https://git.chanpaca.net/api/v1/repos/yunchan/d3ro-voice/releases/tags/v1.0.0', {
headers: { 'Authorization': authorization }
});
const relData = await relRes.json();
console.log('Release ID:', relData.id, relData.name);
if (!relData.id) {
console.error('Release not found');
return;
}
const fileBuffer = readFileSync(EXE_PATH);
console.log(`Uploading ${EXE_PATH} (${(fileBuffer.length / (1024*1024)).toFixed(1)} MB)...`);
const formData = new FormData();
formData.append('attachment', new Blob([fileBuffer]), 'D3RO-Voice-Setup-1.0.0-x64.exe');
const uploadRes = await fetch(`https://git.chanpaca.net/api/v1/repos/yunchan/d3ro-voice/releases/${relData.id}/assets?name=D3RO-Voice-Setup-1.0.0-x64.exe`, {
method: 'POST',
headers: {
'Authorization': authorization,
},
body: formData
});
console.log('Upload HTTP Status:', uploadRes.status);
const uploadData = await uploadRes.json();
console.log('Upload Result:', uploadData);
}
uploadAsset().catch(console.error);

View file

@ -5,11 +5,8 @@ const PACKAGE_NAME = 'com.d3ro.voice'
const REQUIRED_RELATION = 'delegate_permission/common.handle_all_urls' const REQUIRED_RELATION = 'delegate_permission/common.handle_all_urls'
const LIVE_URL = 'https://d3ro.chanpaca.net/.well-known/assetlinks.json' const LIVE_URL = 'https://d3ro.chanpaca.net/.well-known/assetlinks.json'
const COMPROMISED_SIGNER_SHA256 = '06eec757722ee7cd3dfbc53202d974aaf0417a7d397f9ef1e5611088ebb2e481' const COMPROMISED_SIGNER_SHA256 = '06eec757722ee7cd3dfbc53202d974aaf0417a7d397f9ef1e5611088ebb2e481'
const SOURCE_PATHS = [ // The landing site (site/, Cloudflare Pages) is the only origin that serves assetlinks.
'site/public/.well-known/assetlinks.json', const SOURCE_PATH = 'site/public/.well-known/assetlinks.json'
'apps/web/public/.well-known/assetlinks.json',
'apps/api-server/wwwroot/.well-known/assetlinks.json',
]
const releaseIdentity = JSON.parse( const releaseIdentity = JSON.parse(
await readFile(resolve('release/android-release-identity.json'), 'utf8'), await readFile(resolve('release/android-release-identity.json'), 'utf8'),
) )
@ -242,19 +239,9 @@ if (expectedFingerprint && expectedFingerprint === forbiddenUploadFingerprint) {
fail('play_and_upload_certificates_must_differ') fail('play_and_upload_certificates_must_differ')
} }
const sourceEvidence = [] const sourceDocument = JSON.parse(await readFile(resolve(SOURCE_PATH), 'utf8'))
for (const path of SOURCE_PATHS) { const canonicalFingerprints = fingerprintsFromDocument(sourceDocument, SOURCE_PATH)
const document = JSON.parse(await readFile(resolve(path), 'utf8')) rejectUploadCertificateAssociation(canonicalFingerprints, forbiddenUploadFingerprint, SOURCE_PATH)
const fingerprints = fingerprintsFromDocument(document, path)
rejectUploadCertificateAssociation(fingerprints, forbiddenUploadFingerprint, path)
sourceEvidence.push({ path, fingerprints })
}
const canonicalFingerprints = sourceEvidence[0].fingerprints
for (const evidence of sourceEvidence.slice(1)) {
if (JSON.stringify(evidence.fingerprints) !== JSON.stringify(canonicalFingerprints)) {
fail(`source_fingerprint_drift_${evidence.path}`)
}
}
if (expectedFingerprint && !canonicalFingerprints.includes(expectedFingerprint)) { if (expectedFingerprint && !canonicalFingerprints.includes(expectedFingerprint)) {
fail('release_certificate_missing_from_source_assetlinks') fail('release_certificate_missing_from_source_assetlinks')
} }
@ -291,5 +278,5 @@ console.log(JSON.stringify({
playConsoleAppId: releaseIdentity.playConsoleAppId, playConsoleAppId: releaseIdentity.playConsoleAppId,
expectedPlayAppSigningCertificateVerified: true, expectedPlayAppSigningCertificateVerified: true,
uploadCertificateExcluded: true, uploadCertificateExcluded: true,
sourcePaths: SOURCE_PATHS, sourcePath: SOURCE_PATH,
}, null, 2)) }, null, 2))

View file

@ -1,88 +0,0 @@
// scripts/deploy-site-to-nas.js
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const MOBILE_RELEASE_PATTERN = /(?:\.apk|\.aab|android[^/\\]*\.zip|signed[^/\\]*\.zip)$/i;
const MOBILE_DOWNLOAD_REFERENCE = /(?:d3ro-voice[^"']*\.apk|git\.chanpaca\.net\/attachments\/(?:0b015367-dd8b-488c-8cc0-4db413b51792|d2e1b123-5678-496a-bf74-bc188938c999))/i;
function assertNoMobileArtifacts(root) {
if (!fs.existsSync(root)) return;
const pending = [root];
while (pending.length > 0) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const candidate = path.join(current, entry.name);
if (entry.isSymbolicLink()) throw new Error(`Refusing symlink in deploy tree: ${candidate}`);
if (entry.isDirectory()) pending.push(candidate);
if (entry.isFile() && MOBILE_RELEASE_PATTERN.test(entry.name)) {
throw new Error(`Unsealed mobile artifact blocked from NAS deploy: ${candidate}`);
}
if (entry.isFile() && /\.(?:html|js|json)$/i.test(entry.name)) {
const source = fs.readFileSync(candidate, 'utf8');
if (MOBILE_DOWNLOAD_REFERENCE.test(source)) {
throw new Error(`Legacy mobile download link blocked from NAS deploy: ${candidate}`);
}
}
}
}
}
async function deploy() {
assertNoMobileArtifacts(path.resolve(__dirname, '../site/dist'));
console.log('=== 1. Building Site ===');
const build = spawnSync('npm', ['run', 'build', '--prefix', 'site'], { stdio: 'inherit', shell: true });
if (build.status !== 0) throw new Error('Site build failed');
assertNoMobileArtifacts(path.resolve(__dirname, '../site/dist'));
console.log('=== 2. Creating tar archive of site/dist ===');
const tarPath = path.resolve(__dirname, '../out/site-dist.tar');
if (!fs.existsSync(path.dirname(tarPath))) fs.mkdirSync(path.dirname(tarPath), { recursive: true });
// Use tar to create archive
const tarRes = spawnSync('tar', ['-cf', tarPath, '-C', 'site/dist', '.'], { stdio: 'inherit', shell: true });
if (tarRes.status !== 0) throw new Error('Tar creation failed');
console.log(`Created archive: ${tarPath} (${(fs.statSync(tarPath).size / 1024 / 1024).toFixed(2)} MB)`);
console.log('=== 3. Uploading archive to Synology NAS via SCP ===');
const scpRes = spawnSync('scp', [
'-o', 'StrictHostKeyChecking=no',
tarPath,
'yunchan@192.168.0.39:/volume1/docker/d3ro/site-dist.tar'
], { stdio: 'inherit', shell: true });
if (scpRes.status !== 0) throw new Error('SCP upload failed');
console.log('=== 4. Extracting on NAS and applying to d3ro_voice_api container ===');
const sshCmd = `
mkdir -p /volume1/docker/d3ro/wwwroot &&
tar -xf /volume1/docker/d3ro/site-dist.tar -C /volume1/docker/d3ro/wwwroot &&
docker cp /volume1/docker/d3ro/wwwroot/. d3ro_voice_api:/app/wwwroot/ &&
docker exec d3ro_voice_api ls -la /app/wwwroot &&
docker exec d3ro_voice_api find /app/wwwroot -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q . && exit 1 || true
`;
const sshRes = spawnSync('ssh', [
'-o', 'StrictHostKeyChecking=no',
'yunchan@192.168.0.39',
sshCmd
], { stdio: 'inherit', shell: true });
if (sshRes.status !== 0) throw new Error('SSH extract failed');
console.log('=== 5. Updating docker-compose.yml on NAS for persistent mount ===');
const updateComposeCmd = `
sed -i 's|- /volume1/docker/d3ro/data:/app/data|- /volume1/docker/d3ro/data:/app/data\\n - /volume1/docker/d3ro/wwwroot:/app/wwwroot|g' /volume1/docker/d3ro/docker-compose.yml || true
`;
spawnSync('ssh', ['-o', 'StrictHostKeyChecking=no', 'yunchan@192.168.0.39', updateComposeCmd], { stdio: 'inherit', shell: true });
console.log('✓ Successfully deployed site and releases to NAS!');
}
module.exports = { assertNoMobileArtifacts };
if (require.main === module) {
deploy().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}

View file

@ -1,234 +0,0 @@
// scripts/verify-e2e-downloads-and-releases.js
// Comprehensive End-to-End Test for D3RO Voice Download Center, Binary Delivery, and Release Hub
const { chromium } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const http = require('http');
const crypto = require('crypto');
const {
forgejoAuthorization,
forgejoLogin,
} = require('./lib/credentials.cjs');
const OUTPUT_DIR = 'C:/Users/encep/.gemini/antigravity-cli/brain/3ab7ae82-5634-41d5-93a7-3c12c22503e2/screenshots/e2e_verification';
const EXPECTED_HASH = 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2';
function serveStatic(dir) {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
let reqPath = req.url.split('?')[0];
if (reqPath === '/' || reqPath === '') reqPath = '/index.html';
const filePath = path.join(dir, reqPath);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath).toLowerCase();
const mime = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.svg': 'image/svg+xml',
'.exe': 'application/vnd.microsoft.portable-executable',
'.blockmap': 'application/octet-stream',
'.yml': 'text/yaml'
};
const stat = fs.statSync(filePath);
res.writeHead(200, {
'Content-Type': mime[ext] || 'application/octet-stream',
'Content-Length': stat.size
});
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404);
res.end('Not Found: ' + reqPath);
}
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
async function runE2E() {
const { username, password } = forgejoLogin();
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const testResults = {
landingPageButtons: false,
downloadPageLoaded: false,
directBinaryDownload200: false,
sha256HashMatch: false,
forgejoReleaseVerified: false,
clientSideVerifierWorks: false,
evidenceScreenshots: []
};
console.log('========================================================');
console.log('🚀 STARTING RIGOROUS E2E VERIFICATION FOR D3RO VOICE');
console.log('========================================================\n');
// 1. Start static server serving site/dist (with public releases synced)
const siteDistDir = path.resolve(__dirname, '../site/dist');
const { server, port } = await serveStatic(siteDistDir);
const baseUrl = `http://127.0.0.1:${port}`;
console.log(`[TEST 1] Static Web Server started on ${baseUrl}`);
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
deviceScaleFactor: 2,
acceptDownloads: true
});
const page = await context.newPage();
// Test 1: Landing Page Download Buttons
console.log('\n[TEST 2] Verifying Landing Page Download CTA buttons...');
await page.goto(baseUrl, { waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
const heroBtnHref = await page.getAttribute('a.glow-btn', 'href');
console.log(' -> Hero download button href:', heroBtnHref);
if (heroBtnHref === '/download.html' || heroBtnHref.includes('download')) {
testResults.landingPageButtons = true;
console.log(' ✓ PASS: Landing page button correctly targets /download.html (no github 404 placeholder)');
} else {
console.error(' ❌ FAIL: Invalid href:', heroBtnHref);
}
const landingShot = path.join(OUTPUT_DIR, '01_landing_page_verified.png');
await page.screenshot({ path: landingShot });
testResults.evidenceScreenshots.push(landingShot);
// Test 2: Navigate to Download Page & Inspect Auto-OS Detection
console.log('\n[TEST 3] Loading Download Center Page (/download.html)...');
await page.goto(`${baseUrl}/download.html`, { waitUntil: 'networkidle' });
await page.waitForTimeout(1500);
const osTitle = await page.textContent('#detectedOsTitle');
const downloadBtnText = await page.textContent('#downloadBtnText');
console.log(' -> Detected OS Title:', osTitle);
console.log(' -> Primary Action Button:', downloadBtnText);
if (osTitle.includes('Windows') && downloadBtnText.includes('Windows')) {
testResults.downloadPageLoaded = true;
console.log(' ✓ PASS: Auto-OS detection correctly identified Windows environment and rendered verified button');
}
const dlCenterShot = path.join(OUTPUT_DIR, '02_download_center_verified.png');
await page.screenshot({ path: dlCenterShot });
testResults.evidenceScreenshots.push(dlCenterShot);
// Test 3: Trigger real binary download & calculate byte-level SHA-256
console.log('\n[TEST 4] Triggering live binary download for D3RO-Voice-Setup-1.0.0-x64.exe...');
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('#primaryDownloadBtn')
]);
const downloadPath = path.join(OUTPUT_DIR, 'downloaded_installer_test.exe');
await download.saveAs(downloadPath);
const downloadedStat = fs.statSync(downloadPath);
const downloadedSizeMb = (downloadedStat.size / (1024 * 1024)).toFixed(2);
console.log(` -> Downloaded binary size: ${downloadedStat.size} bytes (${downloadedSizeMb} MB)`);
if (downloadedStat.size > 100 * 1024 * 1024) {
testResults.directBinaryDownload200 = true;
console.log(' ✓ PASS: Binary downloaded successfully via HTTP 200 (>100MB complete installer payload)');
} else {
console.error(' ❌ FAIL: Download size unexpectedly small:', downloadedStat.size);
}
// Verify SHA-256 hash of downloaded file
const fileBuffer = fs.readFileSync(downloadPath);
const calculatedHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
console.log(' -> Calculated SHA-256:', calculatedHash);
console.log(' -> Expected SHA-256: ', EXPECTED_HASH);
if (calculatedHash === EXPECTED_HASH) {
testResults.sha256HashMatch = true;
console.log(' ✓ PASS: Binary integrity 100% MATCH! Zero corruption or tampering.');
} else {
console.error(' ❌ FAIL: Hash mismatch!');
}
// Test 4: Live Client-Side Hash Verifier Simulation on the page
console.log('\n[TEST 5] Testing Client-Side WebCrypto Drag & Drop Verifier Widget...');
await page.setInputFiles('#fileVerifierInput', downloadPath);
await page.waitForTimeout(3000); // Allow WebCrypto subtle digest to compute
const verifyBadgeText = await page.textContent('#verifyBadge');
console.log(' -> Verifier Badge Output:', verifyBadgeText);
if (verifyBadgeText.includes('정상') || verifyBadgeText.includes('OFFICIAL MATCH')) {
testResults.clientSideVerifierWorks = true;
console.log(' ✓ PASS: Client-side UI verified binary with green check badge!');
}
const verifierShot = path.join(OUTPUT_DIR, '03_client_verifier_passed.png');
await page.screenshot({ path: verifierShot });
testResults.evidenceScreenshots.push(verifierShot);
// Test 5: Verify Remote Forgejo Git Server (https://git.chanpaca.net/yunchan/d3ro-voice/releases)
console.log('\n[TEST 6] Verifying Live Remote Forgejo Git Server (git.chanpaca.net)...');
try {
const authorization = forgejoAuthorization();
const apiRes = await fetch('https://git.chanpaca.net/api/v1/repos/yunchan/d3ro-voice/releases/tags/v1.0.0', {
headers: { 'Authorization': authorization }
});
console.log(' -> Forgejo Release API Status:', apiRes.status);
if (apiRes.status === 200) {
const rel = await apiRes.json();
console.log(` -> Release Name: "${rel.name}", Tag: "${rel.tag_name}"`);
console.log(` -> Total Assets Attached: ${rel.assets.length}`);
if (rel.assets.length > 0) {
console.log(` -> Asset: ${rel.assets[0].name} (${(rel.assets[0].size / (1024*1024)).toFixed(1)} MB)`);
console.log(` -> Asset Download URL: ${rel.assets[0].browser_download_url}`);
testResults.forgejoReleaseVerified = true;
console.log(' ✓ PASS: Forgejo remote server release v1.0.0 is live with downloadable installer attached!');
}
}
} catch (err) {
console.error('Remote check error:', err);
}
// Capture Forgejo Release Page
console.log('\n[TEST 7] Capturing Live Forgejo Release UI...');
try {
await page.goto('https://git.chanpaca.net/user/login', { waitUntil: 'networkidle', timeout: 20000 });
await page.fill('input[name="user_name"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
await page.goto('https://git.chanpaca.net/yunchan/d3ro-voice/releases', { waitUntil: 'networkidle', timeout: 20000 });
await page.waitForTimeout(2000);
const forgejoShot = path.join(OUTPUT_DIR, '04_forgejo_releases_verified.png');
await page.screenshot({ path: forgejoShot, fullPage: true });
testResults.evidenceScreenshots.push(forgejoShot);
console.log(' ✓ Saved Forgejo Releases Screenshot:', forgejoShot);
} catch (e) {
console.warn('Forgejo UI capture warning:', e.message);
}
// Cleanup
fs.unlinkSync(downloadPath);
await browser.close();
server.close();
console.log('\n========================================================');
console.log('📊 FINAL E2E AUDIT SCORECARD:');
console.log('========================================================');
console.log('1. Landing Page Download Links: ', testResults.landingPageButtons ? '✅ 100% PASSED' : '❌ FAILED');
console.log('2. Download Center Rendering: ', testResults.downloadPageLoaded ? '✅ 100% PASSED' : '❌ FAILED');
console.log('3. Direct 102MB Binary Download: ', testResults.directBinaryDownload200 ? '✅ 100% PASSED' : '❌ FAILED');
console.log('4. SHA-256 Hash Integrity Match: ', testResults.sha256HashMatch ? '✅ 100% PASSED' : '❌ FAILED');
console.log('5. Client WebCrypto Verifier: ', testResults.clientSideVerifierWorks ? '✅ 100% PASSED' : '❌ FAILED');
console.log('6. Remote Forgejo Git v1.0.0 Asset:', testResults.forgejoReleaseVerified ? '✅ 100% PASSED' : '❌ FAILED');
console.log('========================================================\n');
fs.writeFileSync(path.join(OUTPUT_DIR, 'e2e_results.json'), JSON.stringify(testResults, null, 2));
}
runE2E().catch(console.error);

View file

@ -1,8 +0,0 @@
version: 1.0.0
files:
- url: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
size: 102123822
path: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
releaseDate: '2026-08-20T01:56:59.299Z'

View file

@ -1,8 +0,0 @@
version: 1.0.0
files:
- url: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
size: 102123822
path: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
releaseDate: '2026-08-20T01:56:59.299Z'

View file

@ -109,11 +109,4 @@ test.describe('D3RO Voice E2E Architecture Verification', () => {
expect(report.userSummaries.length).toBeGreaterThan(0) expect(report.userSummaries.length).toBeGreaterThan(0)
console.log('✓ BackOffice Usage & Cost Analytics verified:', report) console.log('✓ BackOffice Usage & Cost Analytics verified:', report)
}) })
test('6. BackOffice Web SPA Page Loading', async ({ page }) => {
await page.goto(`${API_BASE}/admin/index.html`)
await expect(page.locator('text=D3RO VOICE')).toBeVisible()
await expect(page.locator('text=백엔드 서버 대시보드')).toBeVisible()
console.log('✓ BackOffice Admin SPA UI loaded successfully.')
})
}) })