Compare commits

...

3 Commits

Author SHA1 Message Date
255429d529 fix: pass app_version to CI and ship Windows NSIS installer only
Add app_version to workflow_dispatch inputs and worker dispatch payload. Collect only setup.exe from bundle/nsis, prefer installer URLs in release lookup, and use git-tag-safe job IDs.
2026-07-03 16:55:12 +08:00
6f68837e99 docs: add MIT LICENSE and .dev.vars.example referenced by README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 22:05:51 +08:00
c0075543f0 feat: desktop build support and build list UI improvements
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 21:57:39 +08:00
18 changed files with 583 additions and 54 deletions

15
.dev.vars.example Normal file
View File

@@ -0,0 +1,15 @@
# Wrangler 本地开发密钥(复制为 .dev.vars 后填写真实值)
# cp .dev.vars.example .dev.vars
# GitHub Personal Access Token权限至少包含 repo、workflow
GITHUB_TOKEN=ghp_xxx
# 用于接收构建产物的 GitHub 仓库
GITHUB_OWNER=your-github-username
GITHUB_REPO=Web2App
# 触发 workflow 的默认分支(需与仓库默认分支一致)
DEFAULT_BRANCH=main
# 单次上传 zip 的最大体积MB
MAX_UPLOAD_MB=50

View File

@@ -19,6 +19,10 @@ on:
description: Application bundle identifier
required: true
type: string
app_version:
description: Application version string
required: true
type: string
permissions:
contents: write
@@ -67,14 +71,17 @@ jobs:
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path artifacts/windows | Out-Null
$bundleRoot = "template/src-tauri/target/release/bundle"
if (Test-Path $bundleRoot) {
Get-ChildItem -Path $bundleRoot -Recurse -Include *setup.exe,*.exe,*.msi -ErrorAction SilentlyContinue |
Copy-Item -Destination artifacts/windows -Force
$nsisDir = "template/src-tauri/target/release/bundle/nsis"
if (-not (Test-Path $nsisDir)) {
Write-Error "NSIS bundle directory not found: $nsisDir"
exit 1
}
Get-ChildItem -Path "template/src-tauri/target/release" -Filter "*.exe" -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notmatch "wix|candle|light" } |
Copy-Item -Destination artifacts/windows -Force
$installers = Get-ChildItem -Path $nsisDir -Filter "*-setup.exe"
if (-not $installers) {
Write-Error "No NSIS installer (*-setup.exe) found in $nsisDir"
exit 1
}
$installers | Copy-Item -Destination artifacts/windows -Force
Get-ChildItem artifacts/windows
- uses: actions/upload-artifact@v4
@@ -212,7 +219,7 @@ jobs:
body: |
Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, version: `${{ inputs.app_version }}`, ID: `${{ inputs.app_identifier }}`).
- Windows desktop installer/portable binary
- Windows NSIS installer (*-setup.exe)
- Android APK (arm64-v8a, signed for sideload install)
draft: false
prerelease: true

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules/
frontend/dist/
!frontend/dist/index.html
frontend/dist-desktop/
frontend/.env.desktop
uploads/
data/
.env

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 shumengya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web2App</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
<link rel="apple-touch-icon" href="/logo-192.png" />
</head>
<body>
<div id="root"></div>

View File

@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -b && vite build && vite build --mode desktop",
"build:desktop": "vite build --mode desktop",
"preview": "vite preview"
},
"dependencies": {

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

71
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,71 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e40af"/>
<stop offset="100%" stop-color="#3b82f6"/>
</linearGradient>
<linearGradient id="glow" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.18"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="100" fill="url(#bg)"/>
<!-- Subtle top sheen -->
<rect width="512" height="256" rx="100" fill="url(#glow)"/>
<!-- ===== LEFT: Browser window ===== -->
<rect x="44" y="155" width="192" height="148" rx="14"
fill="white" fill-opacity="0.12" stroke="white" stroke-width="11" stroke-opacity="0.9"/>
<!-- Title bar stripe -->
<rect x="44" y="155" width="192" height="40" rx="14" fill="white" fill-opacity="0.18"/>
<rect x="44" y="181" width="192" height="14" fill="white" fill-opacity="0.18"/>
<!-- Traffic lights -->
<circle cx="70" cy="175" r="7" fill="#ef4444" fill-opacity="0.85"/>
<circle cx="92" cy="175" r="7" fill="#f59e0b" fill-opacity="0.85"/>
<circle cx="114" cy="175" r="7" fill="#22c55e" fill-opacity="0.85"/>
<!-- URL bar -->
<rect x="130" y="165" width="90" height="18" rx="9" fill="white" fill-opacity="0.28"/>
<!-- Page content lines -->
<rect x="64" y="212" width="152" height="10" rx="5" fill="white" fill-opacity="0.55"/>
<rect x="64" y="232" width="118" height="10" rx="5" fill="white" fill-opacity="0.38"/>
<rect x="64" y="252" width="134" height="10" rx="5" fill="white" fill-opacity="0.38"/>
<rect x="64" y="272" width="96" height="10" rx="5" fill="white" fill-opacity="0.25"/>
<!-- ===== MIDDLE: Conversion arrow ===== -->
<!-- Shaft -->
<line x1="256" y1="256" x2="302" y2="256"
stroke="white" stroke-width="14" stroke-linecap="round" stroke-opacity="0.95"/>
<!-- Arrowhead -->
<polyline points="284,236 308,256 284,276"
fill="none" stroke="white" stroke-width="14"
stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>
<!-- Sparkle particles -->
<circle cx="264" cy="222" r="5.5" fill="white" fill-opacity="0.72"/>
<circle cx="280" cy="212" r="3.5" fill="white" fill-opacity="0.50"/>
<circle cx="298" cy="218" r="2.5" fill="white" fill-opacity="0.38"/>
<circle cx="268" cy="296" r="4.5" fill="white" fill-opacity="0.62"/>
<circle cx="258" cy="308" r="3" fill="white" fill-opacity="0.42"/>
<circle cx="292" cy="302" r="2.5" fill="white" fill-opacity="0.32"/>
<!-- ===== RIGHT: Phone ===== -->
<rect x="324" y="158" width="130" height="214" rx="24"
fill="white" fill-opacity="0.12" stroke="white" stroke-width="11" stroke-opacity="0.9"/>
<!-- Punch-hole camera -->
<circle cx="389" cy="175" r="7" fill="white" fill-opacity="0.45"/>
<!-- Screen area -->
<rect x="340" y="192" width="98" height="156" rx="7" fill="white" fill-opacity="0.10"/>
<!-- App icon grid (3×2) -->
<rect x="348" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="382" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="416" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.38"/>
<rect x="348" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="382" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.38"/>
<rect x="416" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.28"/>
<!-- Bottom mini-bar rows -->
<rect x="348" y="272" width="94" height="8" rx="4" fill="white" fill-opacity="0.25"/>
<rect x="348" y="288" width="72" height="8" rx="4" fill="white" fill-opacity="0.18"/>
<!-- Home indicator bar -->
<rect x="361" y="356" width="56" height="6" rx="3" fill="white" fill-opacity="0.65"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,11 +1,15 @@
import { Link, Outlet } from "react-router-dom";
import { Link, Outlet, useLocation } from "react-router-dom";
export default function Layout() {
const { pathname } = useLocation();
const isBuilds = pathname === "/jobs";
return (
<div className="app-shell">
<header className="site-header">
<div className="site-header-inner">
<Link to="/" className="site-brand">
<img src="/logo.svg" alt="" className="site-logo" />
Web2App
</Link>
<nav className="site-nav">
@@ -15,7 +19,7 @@ export default function Layout() {
</div>
</header>
<main className="site-main">
<div className="content">
<div className={isBuilds ? "content content-wide" : "content"}>
<Outlet />
</div>
</main>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { fetchBuilds, statusLabel, type Build } from "../api/client";
import { getBuildDuration, isBuildFinished } from "../lib/duration";
@@ -6,6 +6,8 @@ import { getBuildDuration, isBuildFinished } from "../lib/duration";
export default function BuildList() {
const [builds, setBuilds] = useState<Build[]>([]);
const [error, setError] = useState<string | null>(null);
const [selectedPkg, setSelectedPkg] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
fetchBuilds()
@@ -15,35 +17,136 @@ export default function BuildList() {
);
}, []);
return (
<>
<h1 className="page-title"></h1>
<p className="page-lead"> 20 </p>
const packages = useMemo(() => {
const map = new Map<
string,
{ appName: string; latestAt: string; count: number }
>();
for (const b of builds) {
const existing = map.get(b.appIdentifier);
if (!existing) {
map.set(b.appIdentifier, {
appName: b.appName,
latestAt: b.createdAt,
count: 1,
});
} else {
existing.count++;
if (b.createdAt > existing.latestAt) {
existing.latestAt = b.createdAt;
existing.appName = b.appName;
}
}
}
return [...map.entries()]
.sort((a, b) => b[1].latestAt.localeCompare(a[1].latestAt))
.map(([appIdentifier, { appName, count }]) => ({
appIdentifier,
appName,
count,
}));
}, [builds]);
<section className="doc-section">
{error ? <p className="error-text">{error}</p> : null}
{!error && builds.length === 0 ? (
<p className="prose"></p>
) : null}
{builds.length > 0 ? (
<ul className="build-list">
{builds.map((build) => (
<li key={build.id}>
<Link to={`/jobs/${build.id}`}>
{build.appName} ({build.appNameEn})
</Link>
<div className="meta-line">
v{build.appVersion} · {statusLabel(build.status)}
{isBuildFinished(build.status)
? ` · 耗时 ${getBuildDuration(build).text}`
: null}{" "}
· {new Date(build.createdAt).toLocaleString()}
</div>
</li>
))}
</ul>
) : null}
</section>
</>
const filtered = selectedPkg
? builds.filter((b) => b.appIdentifier === selectedPkg)
: builds;
function selectPkg(pkg: string | null) {
setSelectedPkg(pkg);
setSidebarOpen(false);
}
return (
<div className="builds-layout">
{sidebarOpen && (
<div
className="sidebar-overlay"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside className={`builds-sidebar${sidebarOpen ? " sidebar-open" : ""}`}>
<div className="sidebar-header">
<span className="sidebar-title"></span>
<button
className="sidebar-close"
onClick={() => setSidebarOpen(false)}
aria-label="关闭侧边栏"
>
</button>
</div>
<ul className="pkg-list">
<li>
<button
className={`pkg-item${!selectedPkg ? " pkg-item-active" : ""}`}
onClick={() => selectPkg(null)}
>
<span className="pkg-info">
<span className="pkg-name"></span>
</span>
<span className="pkg-count">{builds.length}</span>
</button>
</li>
{packages.map(({ appIdentifier, appName, count }) => (
<li key={appIdentifier}>
<button
className={`pkg-item${selectedPkg === appIdentifier ? " pkg-item-active" : ""}`}
onClick={() => selectPkg(appIdentifier)}
>
<span className="pkg-info">
<span className="pkg-name">{appName}</span>
<span className="pkg-en">{appIdentifier}</span>
</span>
<span className="pkg-count">{count}</span>
</button>
</li>
))}
</ul>
</aside>
<div className="builds-main">
<div className="builds-topbar">
<button
className="sidebar-toggle"
onClick={() => setSidebarOpen(true)}
aria-label="打开包名筛选"
>
</button>
<h1 className="page-title"></h1>
</div>
<p className="page-lead">
{selectedPkg
? `${selectedPkg} 的构建记录`
: "最近 20 条构建任务。"}
</p>
<section className="doc-section">
{error ? <p className="error-text">{error}</p> : null}
{!error && filtered.length === 0 ? (
<p className="prose"></p>
) : null}
{filtered.length > 0 ? (
<ul className="build-list">
{filtered.map((build) => (
<li key={build.id}>
<Link to={`/jobs/${build.id}`}>
{build.appName} ({build.appNameEn})
</Link>
<div className="meta-line">
v{build.appVersion} · {statusLabel(build.status)}
{isBuildFinished(build.status)
? ` · 耗时 ${getBuildDuration(build).text}`
: null}{" "}
· {new Date(build.createdAt).toLocaleString()}
</div>
</li>
))}
</ul>
) : null}
</section>
</div>
</div>
);
}

View File

@@ -74,6 +74,9 @@ a:hover {
font-size: 1.125rem;
color: var(--text);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.site-brand:hover {
@@ -81,6 +84,13 @@ a:hover {
color: var(--accent);
}
.site-logo {
width: 28px;
height: 28px;
border-radius: 6px;
flex-shrink: 0;
}
.site-nav {
display: flex;
gap: 1rem;
@@ -97,6 +107,10 @@ a:hover {
margin: 0 auto;
}
.content-wide {
max-width: 64rem;
}
.page-title {
margin: 0 0 0.25rem;
font-size: 1.75rem;
@@ -355,6 +369,209 @@ a:hover {
color: var(--muted);
}
/* ===== Builds page layout ===== */
.builds-layout {
display: flex;
gap: 1.25rem;
align-items: flex-start;
}
.builds-sidebar {
width: 190px;
flex-shrink: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0;
position: sticky;
top: 1rem;
}
.sidebar-header {
display: none;
}
.sidebar-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sidebar-close {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
color: var(--muted);
line-height: 1;
}
.sidebar-close:hover {
color: var(--text);
}
.pkg-list {
list-style: none;
margin: 0;
padding: 0;
}
.pkg-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.875rem;
text-align: left;
background: none;
border: none;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
color: var(--text);
border-radius: 0;
transition: background 0.1s;
}
.pkg-item:hover {
background: var(--bg);
}
.pkg-item-active {
color: var(--accent);
font-weight: 600;
background: #eff6ff;
}
.pkg-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.pkg-name {
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pkg-en {
font-size: 0.75rem;
color: var(--muted);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pkg-item-active .pkg-en {
color: #93c5fd;
}
.pkg-count {
flex-shrink: 0;
background: #e5e7eb;
color: #6b7280;
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 10px;
min-width: 1.375rem;
text-align: center;
line-height: 1.5;
}
.pkg-item-active .pkg-count {
background: #bfdbfe;
color: var(--accent);
}
.builds-main {
flex: 1;
min-width: 0;
}
.builds-topbar {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.25rem;
}
.sidebar-toggle {
display: none;
}
.sidebar-overlay {
display: none;
}
@media (max-width: 767px) {
.builds-layout {
display: block;
}
.sidebar-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
padding: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
font-size: 1.125rem;
color: var(--text);
}
.builds-sidebar {
position: fixed;
top: 0;
left: 0;
width: 280px;
max-width: 85vw;
height: 100vh;
z-index: 200;
overflow-y: auto;
transform: translateX(-100%);
transition: transform 0.25s ease;
border-radius: 0;
border: none;
border-right: 1px solid var(--border);
padding-top: 0;
}
.builds-sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.25rem;
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 199;
}
}
@media (min-width: 768px) {
:root {
--page-pad: 1.5rem;

View File

@@ -1,7 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
export default defineConfig(({ mode }) => ({
plugins: [react()],
server: {
port: 5173,
@@ -13,7 +13,7 @@ export default defineConfig({
},
},
build: {
outDir: "dist",
outDir: mode === "desktop" ? "dist-desktop" : "dist",
emptyOutDir: true,
},
});
}));

View File

@@ -11,6 +11,7 @@
"scripts": {
"dev": "concurrently -n web,worker -c green,blue \"npm run dev -w frontend\" \"wrangler dev\"",
"build": "npm run build -w frontend",
"build:desktop": "npm run build:desktop -w frontend",
"deploy": "npm run build && wrangler deploy",
"db:migrate:local": "wrangler d1 migrations apply web2app --local",
"db:migrate:remote": "wrangler d1 migrations apply web2app --remote"

BIN
web2app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

71
web2app-icon.svg Normal file
View File

@@ -0,0 +1,71 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e40af"/>
<stop offset="100%" stop-color="#3b82f6"/>
</linearGradient>
<linearGradient id="glow" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.18"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="100" fill="url(#bg)"/>
<!-- Subtle top sheen -->
<rect width="512" height="256" rx="100" fill="url(#glow)"/>
<!-- ===== LEFT: Browser window ===== -->
<rect x="44" y="155" width="192" height="148" rx="14"
fill="white" fill-opacity="0.12" stroke="white" stroke-width="11" stroke-opacity="0.9"/>
<!-- Title bar stripe -->
<rect x="44" y="155" width="192" height="40" rx="14" fill="white" fill-opacity="0.18"/>
<rect x="44" y="181" width="192" height="14" fill="white" fill-opacity="0.18"/>
<!-- Traffic lights -->
<circle cx="70" cy="175" r="7" fill="#ef4444" fill-opacity="0.85"/>
<circle cx="92" cy="175" r="7" fill="#f59e0b" fill-opacity="0.85"/>
<circle cx="114" cy="175" r="7" fill="#22c55e" fill-opacity="0.85"/>
<!-- URL bar -->
<rect x="130" y="165" width="90" height="18" rx="9" fill="white" fill-opacity="0.28"/>
<!-- Page content lines -->
<rect x="64" y="212" width="152" height="10" rx="5" fill="white" fill-opacity="0.55"/>
<rect x="64" y="232" width="118" height="10" rx="5" fill="white" fill-opacity="0.38"/>
<rect x="64" y="252" width="134" height="10" rx="5" fill="white" fill-opacity="0.38"/>
<rect x="64" y="272" width="96" height="10" rx="5" fill="white" fill-opacity="0.25"/>
<!-- ===== MIDDLE: Conversion arrow ===== -->
<!-- Shaft -->
<line x1="256" y1="256" x2="302" y2="256"
stroke="white" stroke-width="14" stroke-linecap="round" stroke-opacity="0.95"/>
<!-- Arrowhead -->
<polyline points="284,236 308,256 284,276"
fill="none" stroke="white" stroke-width="14"
stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>
<!-- Sparkle particles -->
<circle cx="264" cy="222" r="5.5" fill="white" fill-opacity="0.72"/>
<circle cx="280" cy="212" r="3.5" fill="white" fill-opacity="0.50"/>
<circle cx="298" cy="218" r="2.5" fill="white" fill-opacity="0.38"/>
<circle cx="268" cy="296" r="4.5" fill="white" fill-opacity="0.62"/>
<circle cx="258" cy="308" r="3" fill="white" fill-opacity="0.42"/>
<circle cx="292" cy="302" r="2.5" fill="white" fill-opacity="0.32"/>
<!-- ===== RIGHT: Phone ===== -->
<rect x="324" y="158" width="130" height="214" rx="24"
fill="white" fill-opacity="0.12" stroke="white" stroke-width="11" stroke-opacity="0.9"/>
<!-- Punch-hole camera -->
<circle cx="389" cy="175" r="7" fill="white" fill-opacity="0.45"/>
<!-- Screen area -->
<rect x="340" y="192" width="98" height="156" rx="7" fill="white" fill-opacity="0.10"/>
<!-- App icon grid (3×2) -->
<rect x="348" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="382" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="416" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.38"/>
<rect x="348" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="382" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.38"/>
<rect x="416" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.28"/>
<!-- Bottom mini-bar rows -->
<rect x="348" y="272" width="94" height="8" rx="4" fill="white" fill-opacity="0.25"/>
<rect x="348" y="288" width="72" height="8" rx="4" fill="white" fill-opacity="0.18"/>
<!-- Home indicator bar -->
<rect x="361" y="356" width="56" height="6" rx="3" fill="white" fill-opacity="0.65"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,4 +1,10 @@
import { nanoid } from "nanoid";
import { customAlphabet } from "nanoid";
// GitHub published releases reject tag names ending with '-'.
const generateJobId = customAlphabet(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
10,
);
import type { Env } from "../env";
import {
getBuild,
@@ -112,7 +118,7 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
const buffer = new Uint8Array(await file.arrayBuffer());
const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env));
const jobId = nanoid(10);
const jobId = generateJobId();
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
const appIdentifier = normalizeAppIdentifier(
identifierInput || `com.web2app.${slug}`,
@@ -148,6 +154,7 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
appName: appNameZh,
appNameEn,
appIdentifier,
appVersion,
});
await updateBuild(env, jobId, {

View File

@@ -115,6 +115,7 @@ export async function triggerBuildWorkflow(
appName: string;
appNameEn: string;
appIdentifier: string;
appVersion: string;
},
): Promise<number> {
const { owner, repo, branch } = getGitHubConfig(env);
@@ -133,6 +134,7 @@ export async function triggerBuildWorkflow(
app_name: input.appName,
app_name_en: input.appNameEn,
app_identifier: input.appIdentifier,
app_version: input.appVersion,
},
}),
},
@@ -217,18 +219,19 @@ export async function getReleaseAssets(
assets: Array<{ name: string; browser_download_url: string }>;
};
let windowsUrl: string | null = null;
let windowsInstallerUrl: string | null = null;
let windowsFallbackUrl: string | null = null;
let androidUrl: string | null = null;
for (const asset of release.assets) {
const name = asset.name.toLowerCase();
if (
!windowsUrl &&
(name.endsWith(".exe") ||
name.endsWith(".msi") ||
name.includes("windows"))
if (name.endsWith("-setup.exe") || name.endsWith(".msi")) {
windowsInstallerUrl = asset.browser_download_url;
} else if (
!windowsFallbackUrl &&
name.endsWith(".exe")
) {
windowsUrl = asset.browser_download_url;
windowsFallbackUrl = asset.browser_download_url;
}
if (
!androidUrl &&
@@ -238,7 +241,10 @@ export async function getReleaseAssets(
}
}
return { windowsUrl, androidUrl };
return {
windowsUrl: windowsInstallerUrl ?? windowsFallbackUrl,
androidUrl,
};
}
export function getActionsRunUrl(env: Env, runId: number | null): string | null {