Add Spark Messenger marketing site for Singapore host.

Serve sparkmessenger/sparkmsg on shared Nginx SNI with deploy script and Let's Encrypt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-22 15:06:37 +08:00
parent 7f683ce150
commit 5b53dd906c
10 changed files with 886 additions and 2 deletions

View File

@ -31,6 +31,7 @@ HTTP 和 TCP 调同一套 `internal/module/*/logic`。Gin handler 只做绑定
| 用途 | 域名 | 说明 |
| --- | --- | --- |
| 官网 | `https://sparkmessenger.wyrs365.com` | App 落地页(别名 `sparkmsg.wyrs365.com`) |
| HTTP API | `https://sparkapi.wyrs365.com` | REST `/v1/...`,文档 `/docs` |
| IM 长连接 | `sparkim.wyrs365.com:443` | TLS + 自定义 Frame,不是 WebSocket |
| 对象存储 | `https://sparkr.wyrs365.com` | RustFS S3 path-style,Presigned PUT/GET |

View File

@ -8,6 +8,7 @@ Client --TLS--> :443 ssl_preread
│ → proxy_pass http://127.0.0.1:18080 + X-Real-IP
├─ SNI sparkr.wyrs365.com → 127.0.0.1:8443 (HTTP TLS)
│ → proxy_pass http://127.0.0.1:9000 RustFS S3
├─ SNI sparkmessenger / sparkmsg → 127.0.0.1:8443 (静态官网)
└─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS)
→ PROXY protocol → 127.0.0.1:9090 gnet
```
@ -16,8 +17,15 @@ On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/
- `host/sparkapi.conf` → `/etc/nginx/conf.d/`
- `host/sparkr.conf` → `/etc/nginx/conf.d/` (RustFS S3; DNS A record `sparkr.wyrs365.com` → this host)
- `host/sparkmessenger.conf` → `/etc/nginx/conf.d/`(官网;`sparkmessenger.wyrs365.com` / `sparkmsg.wyrs365.com`)
- `host/spark-sni.conf` → `/etc/nginx/stream.d/`
官网静态文件在 `/var/www/sparkmessenger.wyrs365.com`,部署:
```bash
bash deploy/website/deploy.sh
```
Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443. `00-spark-http.conf` sets `real_ip_header proxy_protocol` and `port_in_redirect off` so `$remote_addr` is the client and redirects stay on 443.
spark-server (Docker):

View File

@ -0,0 +1,53 @@
# Spark Messenger website — sparkmessenger.wyrs365.com / sparkmsg.wyrs365.com
# Public :443 is stream ssl_preread (see stream.d/spark-sni.conf).
# TLS terminates on loopback :8443 (PROXY protocol from stream).
server {
listen 80;
listen [::]:80;
server_name sparkmessenger.wyrs365.com sparkmsg.wyrs365.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 127.0.0.1:8443 ssl proxy_protocol;
listen [::1]:8443 ssl proxy_protocol;
http2 on;
server_name sparkmessenger.wyrs365.com sparkmsg.wyrs365.com;
ssl_certificate /etc/letsencrypt/live/sparkmessenger.wyrs365.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sparkmessenger.wyrs365.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SparkSSL:10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
root /var/www/sparkmessenger.wyrs365.com;
index index.html;
location = /favicon.ico {
try_files /assets/favicon.svg =404;
default_type image/svg+xml;
access_log off;
expires 7d;
}
location /assets/ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -58,8 +58,8 @@ rewrite_http_443() {
local f
for f in /etc/nginx/conf.d/*.conf; do
case "$(basename "$f")" in
sparkapi.conf|sparkr.conf|spark-acme.conf|00-spark-http.conf) continue ;;
spark-acme-*) continue ;;
sparkapi.conf|sparkr.conf|sparkmessenger.conf|spark-acme.conf|00-spark-http.conf) continue ;;
spark-acme-*|sparkmessenger-acme.conf) continue ;;
esac
if grep -qE 'listen[[:space:]]+127\.0\.0\.1:8443' "$f"; then
continue
@ -77,9 +77,13 @@ install_sni() {
rewrite_http_443
rm -f /etc/nginx/conf.d/spark-acme.conf
rm -f /etc/nginx/conf.d/spark-acme-*.conf
rm -f /etc/nginx/conf.d/sparkmessenger-acme.conf
cp "$HOST_DIR/00-spark-http.conf" /etc/nginx/conf.d/00-spark-http.conf
cp "$HOST_DIR/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf
cp "$HOST_DIR/sparkr.conf" /etc/nginx/conf.d/sparkr.conf
if [[ -f "$HOST_DIR/sparkmessenger.conf" ]] && cert_ok sparkmessenger.wyrs365.com; then
cp "$HOST_DIR/sparkmessenger.conf" /etc/nginx/conf.d/sparkmessenger.conf
fi
cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.conf
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'

120
deploy/website/deploy.sh Normal file
View File

@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Deploy Spark Messenger website to the Singapore ECS host.
# Layout on host:
# /var/www/sparkmessenger.wyrs365.com static site
# /etc/nginx/conf.d/sparkmessenger.conf TLS vhost (loopback :8443)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
HOST="${SPARK_SSH_HOST:-root@47.236.66.9}"
KEY="${SPARK_SSH_KEY:-$HOME/.ssh/wyrs_shanghai_deploy.pem}"
SITE_SRC="$ROOT/website"
SITE_DST="/var/www/sparkmessenger.wyrs365.com"
DOMAINS=(sparkmessenger.wyrs365.com sparkmsg.wyrs365.com)
PRIMARY="${DOMAINS[0]}"
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST")
SCP=(scp -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
need() {
command -v "$1" >/dev/null || {
echo "need $1 on this machine" >&2
exit 1
}
}
need ssh
need scp
need rsync
if [[ ! -f "$KEY" ]]; then
echo "missing SSH key: $KEY" >&2
exit 1
fi
if [[ ! -f "$SITE_SRC/index.html" ]]; then
echo "missing $SITE_SRC/index.html" >&2
exit 1
fi
echo "==> upload site + nginx conf"
"${SSH[@]}" "mkdir -p '$SITE_DST' /var/www/certbot /tmp/spark-nginx-host"
rsync -az --delete -e "ssh -i $KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \
"$SITE_SRC/" "$HOST:$SITE_DST/"
"${SCP[@]}" "$ROOT/deploy/nginx/host/sparkmessenger.conf" "$HOST:/tmp/spark-nginx-host/sparkmessenger.conf"
echo "==> ensure TLS certificates"
"${SSH[@]}" bash -s -- "$PRIMARY" "${DOMAINS[@]}" <<'REMOTE'
set -euo pipefail
PRIMARY=$1
shift
DOMAINS=("$@")
cert_ok() {
[[ -f "/etc/letsencrypt/live/$1/fullchain.pem" ]]
}
need_issue=0
if ! cert_ok "$PRIMARY"; then
need_issue=1
else
# Ensure SAN covers all website domains; re-issue if missing.
for d in "${DOMAINS[@]}"; do
if ! openssl x509 -in "/etc/letsencrypt/live/$PRIMARY/fullchain.pem" -noout -text 2>/dev/null | grep -q "$d"; then
need_issue=1
break
fi
done
fi
if [[ "$need_issue" -eq 1 ]]; then
mkdir -p /var/www/certbot
cat > /etc/nginx/conf.d/sparkmessenger-acme.conf <<EOF
server {
listen 80;
listen [::]:80;
server_name ${DOMAINS[*]};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 404;
}
}
EOF
nginx -t
systemctl reload nginx
certbot_args=(certonly --webroot -w /var/www/certbot --non-interactive --agree-tos --keep-until-expiring --cert-name "$PRIMARY")
# Use a placeholder email if register is needed; prefer existing account.
if [[ -n "${CERTBOT_EMAIL:-}" ]]; then
certbot_args+=(--email "$CERTBOT_EMAIL")
else
certbot_args+=(--register-unsafely-without-email)
fi
for d in "${DOMAINS[@]}"; do
certbot_args+=(-d "$d")
done
certbot "${certbot_args[@]}"
rm -f /etc/nginx/conf.d/sparkmessenger-acme.conf
fi
if ! cert_ok "$PRIMARY"; then
echo "certificate missing for $PRIMARY" >&2
exit 1
fi
cp /tmp/spark-nginx-host/sparkmessenger.conf /etc/nginx/conf.d/sparkmessenger.conf
nginx -t
systemctl reload nginx
echo "website nginx ready"
REMOTE
echo "==> verify"
for d in "${DOMAINS[@]}"; do
code=$("${SSH[@]}" "curl -fsS -o /dev/null -w '%{http_code}' --resolve ${d}:443:127.0.0.1 https://${d}/" || true)
echo "$d -> HTTP $code"
done
echo "done"

18
website/README.md Normal file
View File

@ -0,0 +1,18 @@
# Spark Messenger 官网
静态落地页,部署到新加坡 ECS(`47.236.66.9`)。
| 域名 | 说明 |
| --- | --- |
| `https://sparkmessenger.wyrs365.com` | 主站 |
| `https://sparkmsg.wyrs365.com` | 短域名,同一站点 |
主机路径:`/var/www/sparkmessenger.wyrs365.com`
Nginx:`deploy/nginx/host/sparkmessenger.conf` → `/etc/nginx/conf.d/`
```bash
# 默认 KEY=$HOME/.ssh/wyrs_shanghai_deploy.pem HOST=root@47.236.66.9
bash deploy/website/deploy.sh
```
会签发 / 续期 Let’s Encrypt 证书(SAN 含两个域名),rsync 静态文件并 reload nginx。

14
website/assets/app.js Normal file
View File

@ -0,0 +1,14 @@
(() => {
const form = document.querySelector(".notify");
const note = document.getElementById("notify-note");
if (!form || !note) return;
form.addEventListener("submit", (event) => {
event.preventDefault();
const email = form.querySelector("input[type='email']");
if (!(email instanceof HTMLInputElement) || !email.value.trim()) return;
note.hidden = false;
email.value = "";
email.blur();
});
})();

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#1a221c"/>
<path d="M16 4.5 19.2 12.6 28 14.5 20.8 19.8 23.2 28 16 23.2 8.8 28 11.2 19.8 4 14.5 12.8 12.6Z" fill="#e35a1c"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

555
website/assets/styles.css Normal file
View File

@ -0,0 +1,555 @@
:root {
--ink: #1a221c;
--ink-soft: #3d4a40;
--mist: #f3efe6;
--paper: #faf7f0;
--ember: #e35a1c;
--ember-deep: #b84312;
--leaf: #2f6b4f;
--line: rgba(26, 34, 28, 0.12);
--shadow: 0 28px 60px rgba(26, 34, 28, 0.14);
--font-display: "Bricolage Grotesque", "Segoe UI", sans-serif;
--font-body: "Source Serif 4", Georgia, serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: var(--font-body);
font-size: 1.125rem;
line-height: 1.55;
background:
radial-gradient(1200px 700px at 12% -10%, #ffe2c4 0%, transparent 55%),
radial-gradient(900px 600px at 100% 8%, #cfe8d7 0%, transparent 50%),
linear-gradient(180deg, #f7f2e8 0%, var(--mist) 45%, #e8efe8 100%);
overflow-x: hidden;
}
.grain {
pointer-events: none;
position: fixed;
inset: 0;
z-index: 50;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
a {
color: inherit;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.top {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
max-width: 1120px;
margin: 0 auto;
padding: 1.25rem 1.5rem 0;
}
.mark {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-family: var(--font-display);
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.02em;
text-decoration: none;
}
.mark-icon {
width: 1.35rem;
height: 1.35rem;
fill: var(--ember);
}
.top-nav {
display: flex;
gap: 1.25rem;
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 500;
}
.top-nav a {
text-decoration: none;
opacity: 0.78;
}
.top-nav a:hover {
opacity: 1;
}
.hero {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
align-items: center;
gap: 2rem 3rem;
max-width: 1120px;
min-height: calc(100vh - 4.5rem);
margin: 0 auto;
padding: 2.5rem 1.5rem 3.5rem;
}
.hero-copy {
max-width: 34rem;
}
.eyebrow {
margin: 0 0 0.75rem;
font-family: var(--font-display);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--leaf);
}
.hero h1 {
margin: 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(2.8rem, 7vw, 5.2rem);
line-height: 0.95;
letter-spacing: -0.045em;
}
.lede {
margin: 1.25rem 0 0;
max-width: 28rem;
font-size: clamp(1.1rem, 2vw, 1.35rem);
color: var(--ink-soft);
}
.cta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.75rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.85rem;
padding: 0.7rem 1.25rem;
border-radius: 0.55rem;
border: 1px solid transparent;
font-family: var(--font-display);
font-size: 0.98rem;
font-weight: 600;
letter-spacing: -0.01em;
text-decoration: none;
cursor: pointer;
transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: var(--ember);
color: #fffaf4;
}
.btn-primary:hover {
background: var(--ember-deep);
}
.btn-ghost {
background: transparent;
border-color: var(--line);
color: var(--ink);
}
.btn-ghost:hover {
border-color: rgba(26, 34, 28, 0.35);
}
.hero-stage {
position: relative;
min-height: 28rem;
display: grid;
place-items: center;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(2px);
animation: drift 9s ease-in-out infinite alternate;
}
.orb-a {
width: 18rem;
height: 18rem;
background: radial-gradient(circle at 30% 30%, #ffb07a, #e35a1c66 55%, transparent 70%);
top: 8%;
right: 8%;
}
.orb-b {
width: 14rem;
height: 14rem;
background: radial-gradient(circle at 40% 40%, #8fd0ad, #2f6b4f55 60%, transparent 72%);
bottom: 10%;
left: 4%;
animation-delay: -3s;
}
.device {
position: relative;
width: min(100%, 18.5rem);
aspect-ratio: 9 / 17.5;
border-radius: 2rem;
padding: 0.7rem;
background: linear-gradient(160deg, #2a332c, #151a17);
box-shadow: var(--shadow);
transform: rotate(3deg);
animation: floaty 6.5s ease-in-out infinite;
}
.device-bezel {
height: 100%;
border-radius: 1.45rem;
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, #203028, #101512);
padding: 1.4rem 0.95rem 1rem;
}
.chat {
display: flex;
flex-direction: column;
gap: 0.7rem;
height: 100%;
}
.bubble {
max-width: 88%;
padding: 0.7rem 0.85rem;
border-radius: 1rem;
font-family: var(--font-display);
font-size: 0.92rem;
font-weight: 500;
line-height: 1.35;
}
.bubble.in {
align-self: flex-start;
background: #eef4ef;
color: var(--ink);
border-bottom-left-radius: 0.35rem;
}
.bubble.out {
align-self: flex-end;
background: var(--ember);
color: #fff8f1;
border-bottom-right-radius: 0.35rem;
}
.typing {
align-self: flex-start;
display: inline-flex;
gap: 0.28rem;
padding: 0.75rem 0.9rem;
border-radius: 1rem;
background: #eef4ef;
}
.typing span {
width: 0.38rem;
height: 0.38rem;
border-radius: 50%;
background: #6d7a70;
animation: blink 1.2s infinite ease-in-out;
}
.typing span:nth-child(2) {
animation-delay: 0.15s;
}
.typing span:nth-child(3) {
animation-delay: 0.3s;
}
.rise {
opacity: 0;
transform: translateY(12px);
animation: rise-in 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--d, 0s);
}
.features {
position: relative;
z-index: 1;
max-width: 1120px;
margin: 0 auto;
padding: 1rem 1.5rem 5rem;
}
.features-intro {
max-width: 36rem;
margin-bottom: 2.5rem;
}
.features h2,
.download h2 {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.9rem, 4vw, 2.8rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
}
.features-intro p,
.download > p {
margin: 0.9rem 0 0;
color: var(--ink-soft);
max-width: 34rem;
}
.feature-rail {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
border-top: 1px solid var(--line);
}
.feature-rail li {
padding: 1.6rem 1.4rem 0.4rem 0;
border-bottom: 1px solid var(--line);
}
.feature-rail li + li {
padding-left: 1.4rem;
border-left: 1px solid var(--line);
}
.feature-rail h3 {
margin: 0 0 0.55rem;
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.feature-rail p {
margin: 0;
color: var(--ink-soft);
font-size: 1.02rem;
}
.download {
position: relative;
z-index: 1;
max-width: 1120px;
margin: 0 auto 2rem;
padding: 3rem 1.5rem 4rem;
border-top: 1px solid var(--line);
}
.notify {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1.5rem;
max-width: 32rem;
}
.notify input {
flex: 1 1 14rem;
min-height: 2.85rem;
padding: 0.7rem 0.95rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: rgba(255, 255, 255, 0.55);
color: var(--ink);
font: inherit;
}
.notify input:focus {
outline: 2px solid rgba(227, 90, 28, 0.35);
outline-offset: 1px;
}
.notify .btn {
border: none;
}
.notify-note {
margin: 0.85rem 0 0;
color: var(--leaf);
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 600;
}
.store-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
.store-chip {
font-family: var(--font-display);
font-size: 0.88rem;
font-weight: 600;
color: var(--ink-soft);
border-bottom: 1px solid var(--line);
padding-bottom: 0.15rem;
}
.foot {
position: relative;
z-index: 1;
max-width: 1120px;
margin: 0 auto;
padding: 0 1.5rem 2.5rem;
color: var(--ink-soft);
font-size: 0.95rem;
}
.foot p {
margin: 0.35rem 0;
}
.foot-links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-family: var(--font-display);
font-size: 0.85rem;
}
.foot a {
text-decoration: none;
opacity: 0.85;
}
.foot a:hover {
opacity: 1;
}
@keyframes drift {
from {
transform: translate3d(0, 0, 0) scale(1);
}
to {
transform: translate3d(-12px, 16px, 0) scale(1.05);
}
}
@keyframes floaty {
0%,
100% {
transform: rotate(3deg) translateY(0);
}
50% {
transform: rotate(2deg) translateY(-10px);
}
}
@keyframes rise-in {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.35;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-2px);
}
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
min-height: auto;
padding-top: 1.75rem;
}
.hero-stage {
order: -1;
min-height: 22rem;
}
.device {
width: min(100%, 15.5rem);
transform: rotate(0deg);
}
.feature-rail {
grid-template-columns: 1fr;
}
.feature-rail li + li {
padding-left: 0;
border-left: none;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.orb,
.device,
.typing span,
.rise {
animation: none !important;
}
.rise {
opacity: 1;
transform: none;
}
}

107
website/index.html Normal file
View File

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Spark Messenger — 即时沟通,火花相连</title>
<meta name="description" content="Spark Messenger 是面向好友、群聊与朋友圈的即时通讯应用。安全、简洁、跨端同步。" />
<link rel="canonical" href="https://sparkmessenger.wyrs365.com/" />
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
<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=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/assets/styles.css" />
</head>
<body>
<div class="grain" aria-hidden="true"></div>
<header class="top">
<a class="mark" href="/">
<svg class="mark-icon" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2.5 19.8 12.2 30 14.5 21.8 20.8 24.5 30 16 24.6 7.5 30 10.2 20.8 2 14.5 12.2 12.2Z" />
</svg>
<span>Spark Messenger</span>
</a>
<nav class="top-nav" aria-label="主导航">
<a href="#features">能力</a>
<a href="#download">下载</a>
</nav>
</header>
<main>
<section class="hero" aria-labelledby="brand">
<div class="hero-copy">
<p class="eyebrow">即时通讯</p>
<h1 id="brand">Spark Messenger</h1>
<p class="lede">把对话、群聊与朋友圈收进同一束火花——安静、可靠、随处可达。</p>
<div class="cta">
<a class="btn btn-primary" href="#download">获取应用</a>
<a class="btn btn-ghost" href="https://sparkapi.wyrs365.com/docs" rel="noopener">开发者文档</a>
</div>
</div>
<div class="hero-stage" aria-hidden="true">
<div class="orb orb-a"></div>
<div class="orb orb-b"></div>
<div class="device">
<div class="device-bezel">
<div class="chat">
<div class="bubble in rise" style="--d:0.1s">今晚一起走?</div>
<div class="bubble out rise" style="--d:0.35s">好,我这就出发。</div>
<div class="bubble in rise" style="--d:0.6s">路上见。</div>
<div class="typing rise" style="--d:0.9s"><span></span><span></span><span></span></div>
</div>
</div>
</div>
</div>
</section>
<section id="features" class="features" aria-labelledby="features-title">
<div class="features-intro">
<h2 id="features-title">为真实对话而造</h2>
<p>少一层噪音,多一点连接。消息直达,媒体直传,跨设备保持同一条时间线。</p>
</div>
<ul class="feature-rail">
<li>
<h3>一对一与群聊</h3>
<p>长连接实时推送,撤回与删除按会话清晰落地。</p>
</li>
<li>
<h3>朋友圈</h3>
<p>图片与短文按可见范围发布,不挤占会话列表。</p>
</li>
<li>
<h3>媒体直传</h3>
<p>对象存储签名上传,聊天里只留引用,加载更快。</p>
</li>
</ul>
</section>
<section id="download" class="download" aria-labelledby="download-title">
<h2 id="download-title">准备好了就装上</h2>
<p>Android 与 iOS 客户端陆续开放。留下邮箱,我们会在上架时通知你。</p>
<form class="notify" action="#" method="get" onsubmit="return false;">
<label class="sr-only" for="email">邮箱</label>
<input id="email" name="email" type="email" placeholder="you@example.com" autocomplete="email" required />
<button type="submit" class="btn btn-primary">提醒我</button>
</form>
<p class="notify-note" id="notify-note" hidden>已记下。上架时会联系你。</p>
<div class="store-row" aria-label="应用商店">
<span class="store-chip">App Store · 即将推出</span>
<span class="store-chip">Google Play · 即将推出</span>
</div>
</section>
</main>
<footer class="foot">
<p><strong>Spark Messenger</strong> · wyrs365</p>
<p class="foot-links">
<a href="https://sparkmessenger.wyrs365.com/">sparkmessenger.wyrs365.com</a>
<span aria-hidden="true">·</span>
<a href="https://sparkmsg.wyrs365.com/">sparkmsg.wyrs365.com</a>
</p>
</footer>
<script src="/assets/app.js" defer></script>
</body>
</html>