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:
parent
b6fe588a7c
commit
cd9d199dbf
53 changed files with 43 additions and 3278 deletions
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 { }
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
|
||||||
|
|
@ -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 |
|
|
@ -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>
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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'
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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('없음')
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -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>
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// apps/web/src/app/releases/page.tsx
|
|
||||||
import DownloadPage from '../download/page'
|
|
||||||
|
|
||||||
export default DownloadPage
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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'
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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.')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue