mirror of
https://github.com/ARMSX2/Armsx2-Repo.git
synced 2026-08-24 16:52:58 -07:00
1195 lines
36 KiB
HTML
1195 lines
36 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="dark">
|
|
<meta
|
|
name="description"
|
|
content="Modern PlayStation 2 emulation for iOS."
|
|
>
|
|
<title>ARMSX2 iOS</title>
|
|
<link rel="icon" href="assets/icon.png">
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--background: #121316;
|
|
--surface: rgba(31, 32, 35, 0.5);
|
|
--surface-strong: rgba(31, 32, 35, 0.72);
|
|
--surface-soft: rgba(31, 32, 35, 0.34);
|
|
--border: rgba(255, 255, 255, 0.12);
|
|
--border-strong: rgba(150, 112, 233, 0.56);
|
|
--text: #f8fafc;
|
|
--muted: #d4d4d8;
|
|
--soft: #a1a1aa;
|
|
--accent: #a48fff;
|
|
--accent-soft: #9670e9;
|
|
--accent-strong: #5a1ae5;
|
|
--accent-blue: #1a50e5;
|
|
--green: #89f2b7;
|
|
--amber: #ffd166;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html {
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
background:
|
|
radial-gradient(120vh at 24% -14%, rgba(90, 26, 229, 0.2), rgba(90, 26, 229, 0.12) 30%, transparent 68%),
|
|
radial-gradient(135vh at 82% 20%, rgba(26, 80, 229, 0.15), rgba(26, 80, 229, 0.06) 34%, transparent 70%),
|
|
radial-gradient(95vh at 45% 88%, rgba(150, 112, 233, 0.08), transparent 64%),
|
|
linear-gradient(180deg, #121316 0%, #0d0e12 46%, #121316 100%);
|
|
color: var(--text);
|
|
font-family:
|
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
|
"Segoe UI", sans-serif;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
body::before {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: -1;
|
|
background:
|
|
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 18rem),
|
|
radial-gradient(70rem 38rem at 50% 0%, rgba(164, 143, 255, 0.08), transparent 72%);
|
|
content: "";
|
|
pointer-events: none;
|
|
}
|
|
|
|
a {
|
|
color: inherit;
|
|
}
|
|
|
|
button {
|
|
min-height: 48px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: rgba(31, 32, 35, 0.62);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
font: inherit;
|
|
font-size: 0.96rem;
|
|
font-weight: 750;
|
|
letter-spacing: 0;
|
|
backdrop-filter: blur(18px);
|
|
transition:
|
|
background 140ms ease,
|
|
border-color 140ms ease,
|
|
color 140ms ease,
|
|
transform 140ms ease;
|
|
}
|
|
|
|
button:focus-visible,
|
|
a:focus-visible {
|
|
outline: 3px solid rgba(164, 143, 255, 0.42);
|
|
outline-offset: 3px;
|
|
}
|
|
|
|
button:hover {
|
|
border-color: rgba(164, 143, 255, 0.38);
|
|
background: rgba(31, 32, 35, 0.82);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.page {
|
|
width: min(100%, 1120px);
|
|
margin: 0 auto;
|
|
padding: 22px 16px 52px;
|
|
}
|
|
|
|
.topbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
min-height: 44px;
|
|
padding: 0 2px;
|
|
color: var(--soft);
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
.topbar strong {
|
|
color: var(--text);
|
|
font-weight: 800;
|
|
}
|
|
|
|
.topbar a {
|
|
color: var(--muted);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.top-links {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: end;
|
|
gap: 8px 16px;
|
|
}
|
|
|
|
.top-links a:hover {
|
|
color: var(--text);
|
|
}
|
|
|
|
.visually-hidden {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border: 0;
|
|
}
|
|
|
|
.hero {
|
|
display: grid;
|
|
gap: 30px;
|
|
padding: 46px 0 38px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.hero-copy {
|
|
max-width: 720px;
|
|
}
|
|
|
|
.identity {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 18px;
|
|
margin-bottom: 22px;
|
|
}
|
|
|
|
.app-icon {
|
|
width: clamp(72px, 10vw, 86px);
|
|
flex: 0 0 auto;
|
|
aspect-ratio: 1;
|
|
border: 1px solid rgba(164, 143, 255, 0.22);
|
|
border-radius: 24px;
|
|
box-shadow:
|
|
0 18px 44px rgba(0, 0, 0, 0.36),
|
|
0 0 30px rgba(90, 26, 229, 0.22);
|
|
}
|
|
|
|
.platforms {
|
|
margin: 0 0 8px;
|
|
color: var(--accent);
|
|
font-size: 0.71rem;
|
|
font-weight: 850;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
h1,
|
|
h2,
|
|
h3,
|
|
p {
|
|
margin-block-start: 0;
|
|
}
|
|
|
|
h1 {
|
|
margin-block-end: 0;
|
|
background: linear-gradient(135deg, #ffffff 28%, rgba(255, 255, 255, 0.72));
|
|
background-clip: text;
|
|
color: transparent;
|
|
font-size: clamp(2.35rem, 7.4vw, 4.1rem);
|
|
font-weight: 780;
|
|
line-height: 0.98;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
h2 {
|
|
margin-block-end: 16px;
|
|
font-size: clamp(1.45rem, 5vw, 2.2rem);
|
|
line-height: 1.08;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
h3 {
|
|
margin-block-end: 6px;
|
|
font-size: 1rem;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.subtitle {
|
|
max-width: 660px;
|
|
margin-block-end: 14px;
|
|
color: var(--muted);
|
|
font-size: clamp(1.08rem, 3.8vw, 1.36rem);
|
|
line-height: 1.42;
|
|
}
|
|
|
|
.hero-description {
|
|
max-width: 660px;
|
|
margin-block-end: 0;
|
|
color: var(--soft);
|
|
font-size: 1rem;
|
|
line-height: 1.72;
|
|
}
|
|
|
|
.install-panel {
|
|
display: grid;
|
|
gap: 16px;
|
|
align-self: start;
|
|
min-width: 0;
|
|
padding: 18px;
|
|
border: 1px solid var(--border-strong);
|
|
border-radius: 8px;
|
|
background:
|
|
linear-gradient(145deg, rgba(164, 143, 255, 0.12), rgba(26, 80, 229, 0.08) 42%, transparent 82%),
|
|
var(--surface-strong);
|
|
box-shadow:
|
|
0 24px 72px rgba(0, 0, 0, 0.34),
|
|
0 0 0 1px rgba(164, 143, 255, 0.08),
|
|
0 0 34px rgba(90, 26, 229, 0.14);
|
|
backdrop-filter: blur(24px);
|
|
}
|
|
|
|
.manual-source {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.panel-label {
|
|
margin: 0;
|
|
color: var(--soft);
|
|
font-size: 0.74rem;
|
|
font-weight: 850;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.source-url {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
padding: 13px;
|
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
|
border-radius: 8px;
|
|
background: rgba(18, 19, 22, 0.72);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.source-url code {
|
|
display: block;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
color: var(--muted);
|
|
font-family:
|
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
"Liberation Mono", monospace;
|
|
font-size: 0.86rem;
|
|
line-height: 1.5;
|
|
white-space: normal;
|
|
word-break: normal;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.source-copy {
|
|
display: inline-grid;
|
|
width: 36px;
|
|
min-height: 36px;
|
|
place-items: center;
|
|
padding: 0;
|
|
border-color: rgba(164, 143, 255, 0.34);
|
|
background: rgba(31, 32, 35, 0.64);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.source-copy svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.source-copy:hover {
|
|
color: var(--text);
|
|
transform: none;
|
|
}
|
|
|
|
.install-actions {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.primary-action {
|
|
min-height: 58px;
|
|
border-color: var(--border-strong);
|
|
background: linear-gradient(135deg, var(--accent-soft), var(--accent-strong));
|
|
color: #ffffff;
|
|
font-size: 1.04rem;
|
|
box-shadow:
|
|
0 14px 28px rgba(90, 26, 229, 0.24),
|
|
inset 0 -7px 11px rgba(164, 143, 255, 0.12);
|
|
}
|
|
|
|
.primary-action:hover {
|
|
background: linear-gradient(135deg, #a48fff, #6932e0);
|
|
}
|
|
|
|
.secondary-actions {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.note,
|
|
.status {
|
|
margin: 0;
|
|
color: var(--soft);
|
|
font-size: 0.9rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.status {
|
|
min-height: 1.4em;
|
|
}
|
|
|
|
.metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.metric {
|
|
min-width: 0;
|
|
padding: 13px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--surface);
|
|
}
|
|
|
|
.metric span {
|
|
display: block;
|
|
color: var(--soft);
|
|
font-size: 0.76rem;
|
|
}
|
|
|
|
.metric strong {
|
|
display: block;
|
|
margin-top: 4px;
|
|
color: var(--text);
|
|
font-size: 0.98rem;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.section {
|
|
padding: 32px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: end;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.section-kicker {
|
|
margin: 0 0 6px;
|
|
color: var(--accent);
|
|
font-size: 0.74rem;
|
|
font-weight: 900;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.screenshot-track {
|
|
display: grid;
|
|
grid-auto-columns: min(76vw, 310px);
|
|
grid-auto-flow: column;
|
|
gap: 14px;
|
|
margin: 0 -16px;
|
|
padding: 0 16px 10px;
|
|
overflow-x: auto;
|
|
overscroll-behavior-x: contain;
|
|
scroll-snap-type: x mandatory;
|
|
scrollbar-color: rgba(255, 255, 255, 0.26) transparent;
|
|
}
|
|
|
|
.screenshot-frame {
|
|
margin: 0;
|
|
scroll-snap-align: start;
|
|
}
|
|
|
|
.screenshot-frame img {
|
|
display: block;
|
|
width: 100%;
|
|
aspect-ratio: 9 / 19.5;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: #050509;
|
|
object-fit: cover;
|
|
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.32);
|
|
}
|
|
|
|
.screenshot-frame figcaption {
|
|
margin-top: 10px;
|
|
color: var(--soft);
|
|
font-size: 0.88rem;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
}
|
|
|
|
.content-grid {
|
|
display: grid;
|
|
gap: 28px;
|
|
padding-top: 32px;
|
|
}
|
|
|
|
.priority-grid {
|
|
display: grid;
|
|
gap: 28px;
|
|
padding-top: 32px;
|
|
}
|
|
|
|
.copy-block {
|
|
color: var(--muted);
|
|
font-size: 1rem;
|
|
line-height: 1.75;
|
|
}
|
|
|
|
.copy-block h3 {
|
|
margin: 18px 0 8px;
|
|
color: var(--text);
|
|
font-size: 0.95rem;
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.copy-block h3:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.copy-block p {
|
|
margin-block-end: 14px;
|
|
}
|
|
|
|
.copy-block p:last-child {
|
|
margin-block-end: 0;
|
|
}
|
|
|
|
.detail-list,
|
|
.change-list,
|
|
.requirement-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
|
|
.detail-list li,
|
|
.change-list li,
|
|
.requirement-list li {
|
|
position: relative;
|
|
padding-left: 18px;
|
|
color: var(--muted);
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.detail-list li::before,
|
|
.change-list li::before,
|
|
.requirement-list li::before {
|
|
position: absolute;
|
|
top: 0.68em;
|
|
left: 0;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 99px;
|
|
background: var(--green);
|
|
content: "";
|
|
}
|
|
|
|
.requirement-list li::before {
|
|
background: var(--amber);
|
|
}
|
|
|
|
.requirement-list a {
|
|
color: var(--muted);
|
|
font-weight: 800;
|
|
text-decoration-color: rgba(164, 143, 255, 0.62);
|
|
text-underline-offset: 3px;
|
|
}
|
|
|
|
.requirement-list a:hover {
|
|
color: var(--text);
|
|
}
|
|
|
|
.version-line {
|
|
margin-bottom: 14px;
|
|
color: var(--soft);
|
|
}
|
|
|
|
.integrity {
|
|
display: grid;
|
|
gap: 10px;
|
|
padding-top: 16px;
|
|
color: var(--soft);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.integrity-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px 14px;
|
|
}
|
|
|
|
.integrity code {
|
|
color: var(--muted);
|
|
font-family:
|
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
"Liberation Mono", monospace;
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.integrity a {
|
|
color: var(--accent);
|
|
font-weight: 800;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.loading-surface {
|
|
min-height: 1.2em;
|
|
border-radius: 6px;
|
|
background:
|
|
linear-gradient(
|
|
90deg,
|
|
rgba(255, 255, 255, 0.06),
|
|
rgba(255, 255, 255, 0.13),
|
|
rgba(255, 255, 255, 0.06)
|
|
);
|
|
background-size: 220% 100%;
|
|
animation: loading 1.2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes loading {
|
|
from {
|
|
background-position: 100% 0;
|
|
}
|
|
|
|
to {
|
|
background-position: -100% 0;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.topbar {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.top-links {
|
|
justify-content: start;
|
|
}
|
|
|
|
.identity {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
gap: 18px;
|
|
}
|
|
|
|
.app-icon {
|
|
width: 82px;
|
|
}
|
|
|
|
.install-panel {
|
|
padding: 16px;
|
|
}
|
|
|
|
.source-url {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.source-url code {
|
|
font-size: 0.8rem;
|
|
line-height: 1.45;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.source-copy {
|
|
width: 100%;
|
|
}
|
|
|
|
.metrics,
|
|
.secondary-actions {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 820px) {
|
|
.page {
|
|
padding: 32px 24px 70px;
|
|
}
|
|
|
|
.screenshot-track {
|
|
grid-auto-columns: minmax(235px, 1fr);
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
grid-auto-flow: unset;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow: visible;
|
|
}
|
|
|
|
.content-grid,
|
|
.priority-grid {
|
|
grid-template-columns: minmax(0, 1.1fr) minmax(310px, 0.9fr);
|
|
gap: 48px;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 940px) {
|
|
.hero {
|
|
grid-template-columns: minmax(0, 1fr) minmax(340px, 430px);
|
|
align-items: center;
|
|
gap: clamp(32px, 5vw, 56px);
|
|
min-height: auto;
|
|
padding: clamp(58px, 8vw, 84px) 0 68px;
|
|
}
|
|
|
|
h1 {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.install-panel {
|
|
position: sticky;
|
|
top: 24px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<header class="topbar" aria-label="Page">
|
|
<strong id="source-name">ARMSX2 iOS</strong>
|
|
<nav class="top-links" aria-label="Page sections">
|
|
<a href="#requirements">Requirements</a>
|
|
<a href="#features">Features</a>
|
|
<a href="#integrity">Checksums</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="hero" aria-labelledby="app-title">
|
|
<div class="hero-copy">
|
|
<div class="identity">
|
|
<img class="app-icon" id="app-icon" src="assets/icon.png" alt="">
|
|
<div>
|
|
<p class="platforms">LiveContainer / SideStore / Feather</p>
|
|
<h1 id="app-title">ARMSX2 iOS</h1>
|
|
</div>
|
|
</div>
|
|
<p class="subtitle" id="app-subtitle">
|
|
<span class="loading-surface" aria-hidden="true"> </span>
|
|
</p>
|
|
<p class="hero-description" id="hero-description">
|
|
<span class="loading-surface" aria-hidden="true"> </span>
|
|
</p>
|
|
</div>
|
|
|
|
<aside class="install-panel" aria-label="Install options">
|
|
<div class="metrics" aria-label="Latest version">
|
|
<div class="metric">
|
|
<span>Version</span>
|
|
<strong id="version">Loading</strong>
|
|
</div>
|
|
<div class="metric">
|
|
<span>Updated</span>
|
|
<strong id="date">Loading</strong>
|
|
</div>
|
|
<div class="metric">
|
|
<span>Minimum iOS</span>
|
|
<strong id="minimum-os">Loading</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="install-actions" aria-label="Install actions">
|
|
<button class="primary-action" type="button" data-action="livecontainer">
|
|
Install via LiveContainer
|
|
</button>
|
|
<div class="secondary-actions">
|
|
<button type="button" data-action="sidestore">SideStore</button>
|
|
<button type="button" data-action="feather">Feather</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="manual-source" aria-label="Manual source URL">
|
|
<p class="panel-label">Manual source URL</p>
|
|
<div class="source-url">
|
|
<code id="source-url">Loading apps.json</code>
|
|
<button class="source-copy" type="button" data-action="copy" aria-label="Copy install link" title="Copy install link">
|
|
<span class="visually-hidden">Copy install link</span>
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
|
|
<path d="M8 8.5A2.5 2.5 0 0 1 10.5 6h6A2.5 2.5 0 0 1 19 8.5v6a2.5 2.5 0 0 1-2.5 2.5h-6A2.5 2.5 0 0 1 8 14.5v-6Z" stroke="currentColor" stroke-width="1.8"/>
|
|
<path d="M6 13.5h-.5A2.5 2.5 0 0 1 3 11V5.5A2.5 2.5 0 0 1 5.5 3H11a2.5 2.5 0 0 1 2.5 2.5V6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="note">
|
|
LiveContainer is recommended for first-time installs.
|
|
</p>
|
|
<p class="status" id="status" aria-live="polite"></p>
|
|
</aside>
|
|
</section>
|
|
|
|
<div class="priority-grid">
|
|
<section class="section" id="requirements" aria-labelledby="requirements-title">
|
|
<p class="section-kicker">Before You Install</p>
|
|
<h2 id="requirements-title">Requirements</h2>
|
|
<ul class="requirement-list">
|
|
<li id="minimum-os-requirement">Minimum iOS version loads from apps.json.</li>
|
|
<li>A <a href="https://livecontainer.github.io/docs/guides/jit-support" target="_blank" rel="noopener noreferrer">JIT-capable setup</a> is needed for playable performance.</li>
|
|
<li>LiveContainer automatic import requires LiveContainer 3.7.0 or later.</li>
|
|
<li>ARMSX2 does not include games or BIOS files.</li>
|
|
<li>You need your own legally obtained PS2 BIOS and game backups, such as ISO files.</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section class="section" id="features" aria-labelledby="features-title">
|
|
<p class="section-kicker">Core Features</p>
|
|
<h2 id="features-title">Built for PS2 on iOS</h2>
|
|
<div class="copy-block" id="feature-list"></div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="section" id="screenshots-preview" aria-labelledby="screenshots-title">
|
|
<div class="section-header">
|
|
<div>
|
|
<p class="section-kicker">Preview</p>
|
|
<h2 id="screenshots-title">Screenshots</h2>
|
|
</div>
|
|
</div>
|
|
<div class="screenshot-track" id="screenshots" aria-label="App screenshots"></div>
|
|
</section>
|
|
|
|
<div class="content-grid">
|
|
<section class="section" aria-labelledby="whats-new-title">
|
|
<p class="section-kicker">Latest Update</p>
|
|
<h2 id="whats-new-title">What’s New</h2>
|
|
<p class="version-line" id="whats-new-version">Loading release notes</p>
|
|
<div class="copy-block" id="whats-new"></div>
|
|
</section>
|
|
|
|
<section class="section legal-section" aria-labelledby="legal-title">
|
|
<p class="section-kicker">Trust</p>
|
|
<h2 id="legal-title">Legal & Credits</h2>
|
|
<div class="copy-block" id="legal-details"></div>
|
|
|
|
<div class="integrity" id="integrity">
|
|
<div class="integrity-row" id="integrity-summary">
|
|
<span>Checksum details load from checksums.json.</span>
|
|
</div>
|
|
<a href="checksums.json">View checksums.json</a>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script type="module">
|
|
const sourceRequestUrl = new URL("apps.json", document.baseURI).href;
|
|
const checksumsRequestUrl = new URL("checksums.json", document.baseURI).href;
|
|
const elements = {
|
|
appIcon: document.getElementById("app-icon"),
|
|
appTitle: document.getElementById("app-title"),
|
|
sourceName: document.getElementById("source-name"),
|
|
appSubtitle: document.getElementById("app-subtitle"),
|
|
heroDescription: document.getElementById("hero-description"),
|
|
sourceUrl: document.getElementById("source-url"),
|
|
version: document.getElementById("version"),
|
|
date: document.getElementById("date"),
|
|
minimumOs: document.getElementById("minimum-os"),
|
|
minimumOsRequirement: document.getElementById("minimum-os-requirement"),
|
|
screenshots: document.getElementById("screenshots"),
|
|
whatsNewVersion: document.getElementById("whats-new-version"),
|
|
whatsNew: document.getElementById("whats-new"),
|
|
features: document.getElementById("feature-list"),
|
|
legalDetails: document.getElementById("legal-details"),
|
|
integritySummary: document.getElementById("integrity-summary"),
|
|
status: document.getElementById("status"),
|
|
};
|
|
|
|
let canonicalSourceUrl = sourceRequestUrl;
|
|
let latestVersionManifest;
|
|
|
|
const compactFormatter = new Intl.NumberFormat(undefined, {
|
|
maximumFractionDigits: 1,
|
|
});
|
|
|
|
const absoluteUrl = (urlValue) => new URL(urlValue, document.baseURI).href;
|
|
|
|
const formatBytes = (byteCount) => {
|
|
if (!Number.isFinite(byteCount)) {
|
|
return "Unknown size";
|
|
}
|
|
|
|
const sizeUnits = ["B", "KB", "MB", "GB"];
|
|
const unitIndex = Math.min(
|
|
Math.floor(Math.log(byteCount) / Math.log(1024)),
|
|
sizeUnits.length - 1,
|
|
);
|
|
const unitValue = byteCount / (1024 ** unitIndex);
|
|
|
|
return `${compactFormatter.format(unitValue)} ${sizeUnits[unitIndex]}`;
|
|
};
|
|
|
|
const firstParagraph = (textValue) => textValue
|
|
?.split(/\r?\n\s*\r?\n/)
|
|
.find((paragraph) => paragraph.trim())
|
|
?.trim() || "";
|
|
|
|
const clearElement = (element) => {
|
|
while (element.firstChild) {
|
|
element.firstChild.remove();
|
|
}
|
|
};
|
|
|
|
const appendFormattedParagraph = (targetElement, paragraphLines) => {
|
|
if (!paragraphLines.length) {
|
|
return;
|
|
}
|
|
|
|
const paragraphElement = document.createElement("p");
|
|
paragraphElement.textContent = paragraphLines.join(" ");
|
|
targetElement.append(paragraphElement);
|
|
};
|
|
|
|
const appendFormattedList = (targetElement, listItems) => {
|
|
if (!listItems.length) {
|
|
return;
|
|
}
|
|
|
|
const listElement = document.createElement("ul");
|
|
listElement.className = "detail-list";
|
|
|
|
for (const listItem of listItems) {
|
|
const itemElement = document.createElement("li");
|
|
itemElement.textContent = listItem;
|
|
listElement.append(itemElement);
|
|
}
|
|
|
|
targetElement.append(listElement);
|
|
};
|
|
|
|
const formattedBlocks = (textValue) => textValue
|
|
?.split(/\r?\n\s*\r?\n/)
|
|
.map((block) => block.trim())
|
|
.filter(Boolean)
|
|
.map((block) => {
|
|
const lines = block
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
const headingMatch = lines[0]?.match(/^([^:\n]{1,80}):$/);
|
|
|
|
return {
|
|
heading: headingMatch ? headingMatch[1] : null,
|
|
lines: headingMatch ? lines.slice(1) : lines,
|
|
};
|
|
}) || [];
|
|
|
|
const renderFormattedBlocks = (targetElement, blocks, emptyMessage, options = {}) => {
|
|
clearElement(targetElement);
|
|
|
|
if (!blocks.length) {
|
|
targetElement.textContent = emptyMessage;
|
|
return;
|
|
}
|
|
|
|
for (const block of blocks) {
|
|
let lineIndex = 0;
|
|
|
|
if (block.heading && options.showHeadings !== false) {
|
|
const headingElement = document.createElement("h3");
|
|
headingElement.textContent = block.heading;
|
|
targetElement.append(headingElement);
|
|
}
|
|
|
|
let paragraphLines = [];
|
|
let listItems = [];
|
|
|
|
const flushParagraph = () => {
|
|
appendFormattedParagraph(targetElement, paragraphLines);
|
|
paragraphLines = [];
|
|
};
|
|
|
|
const flushList = () => {
|
|
appendFormattedList(targetElement, listItems);
|
|
listItems = [];
|
|
};
|
|
|
|
for (; lineIndex < block.lines.length; lineIndex += 1) {
|
|
const listItemMatch = block.lines[lineIndex].match(/^-\s+(.+)$/);
|
|
|
|
if (listItemMatch) {
|
|
flushParagraph();
|
|
listItems.push(listItemMatch[1]);
|
|
continue;
|
|
}
|
|
|
|
flushList();
|
|
paragraphLines.push(block.lines[lineIndex]);
|
|
}
|
|
|
|
flushParagraph();
|
|
flushList();
|
|
}
|
|
};
|
|
|
|
const blockByHeading = (blocks, heading) =>
|
|
blocks.find((block) => block.heading?.toLowerCase() === heading.toLowerCase());
|
|
|
|
const renderFeatureHighlights = (targetElement, blocks) => {
|
|
const featuresBlock = blockByHeading(blocks, "Features");
|
|
renderFormattedBlocks(
|
|
targetElement,
|
|
featuresBlock ? [featuresBlock] : [],
|
|
"Feature details are temporarily unavailable.",
|
|
{ showHeadings: false },
|
|
);
|
|
};
|
|
|
|
const renderLegalDetails = (targetElement, blocks) => {
|
|
const legalBlocks = ["Disclaimer", "Credits"]
|
|
.map((heading) => blockByHeading(blocks, heading))
|
|
.filter(Boolean);
|
|
|
|
renderFormattedBlocks(
|
|
targetElement,
|
|
legalBlocks,
|
|
"Legal and credit details are temporarily unavailable.",
|
|
);
|
|
};
|
|
|
|
const renderWhatsNew = (targetElement, releaseNotes) => {
|
|
clearElement(targetElement);
|
|
|
|
const releaseNoteLines = releaseNotes
|
|
?.split(/\r?\n/)
|
|
.map((releaseNoteLine) => releaseNoteLine.trim())
|
|
.filter(Boolean) || [];
|
|
|
|
if (!releaseNoteLines.length) {
|
|
targetElement.textContent = "Release notes are temporarily unavailable.";
|
|
return;
|
|
}
|
|
|
|
const summaryElement = document.createElement("p");
|
|
summaryElement.textContent = releaseNoteLines[0];
|
|
targetElement.append(summaryElement);
|
|
|
|
if (releaseNoteLines.length === 1) {
|
|
return;
|
|
}
|
|
|
|
const releaseList = document.createElement("ul");
|
|
releaseList.className = "change-list";
|
|
|
|
for (const releaseNoteLine of releaseNoteLines.slice(1)) {
|
|
const releaseItem = document.createElement("li");
|
|
releaseItem.textContent = releaseNoteLine;
|
|
releaseList.append(releaseItem);
|
|
}
|
|
|
|
targetElement.append(releaseList);
|
|
};
|
|
|
|
const renderScreenshots = (screenshotUrls, appName) => {
|
|
clearElement(elements.screenshots);
|
|
|
|
if (!Array.isArray(screenshotUrls) || !screenshotUrls.length) {
|
|
elements.screenshots.textContent = "Screenshots are temporarily unavailable.";
|
|
return;
|
|
}
|
|
|
|
const screenshotCaptions = ["Library", "Settings", "Gameplay"];
|
|
|
|
screenshotUrls.forEach((screenshotUrl, screenshotIndex) => {
|
|
const screenshotFrame = document.createElement("figure");
|
|
const screenshotImage = document.createElement("img");
|
|
const screenshotCaption = document.createElement("figcaption");
|
|
|
|
screenshotFrame.className = "screenshot-frame";
|
|
screenshotImage.src = absoluteUrl(screenshotUrl);
|
|
screenshotImage.alt = `${appName} screenshot ${screenshotIndex + 1}`;
|
|
screenshotImage.loading = screenshotIndex === 0 ? "eager" : "lazy";
|
|
screenshotImage.decoding = "async";
|
|
screenshotCaption.textContent = screenshotCaptions[screenshotIndex] || `Screenshot ${screenshotIndex + 1}`;
|
|
|
|
screenshotFrame.append(screenshotImage);
|
|
screenshotFrame.append(screenshotCaption);
|
|
elements.screenshots.append(screenshotFrame);
|
|
});
|
|
};
|
|
|
|
const writeClipboard = async (clipboardText) => {
|
|
if (!navigator.clipboard?.writeText) {
|
|
throw new Error("Clipboard API is unavailable.");
|
|
}
|
|
|
|
await navigator.clipboard.writeText(clipboardText);
|
|
};
|
|
|
|
const renderSource = (sourcePayload) => {
|
|
const [sourceApp] = sourcePayload.apps || [];
|
|
const [currentVersion] = sourceApp?.versions || [];
|
|
|
|
if (!sourceApp || !currentVersion) {
|
|
throw new Error("apps.json does not contain an app version.");
|
|
}
|
|
|
|
latestVersionManifest = currentVersion;
|
|
canonicalSourceUrl = absoluteUrl(sourcePayload.sourceURL || sourceRequestUrl);
|
|
|
|
document.title = sourceApp.name;
|
|
document.querySelector("meta[name='description']").content = sourceApp.subtitle
|
|
|| "Modern PlayStation 2 emulation for iOS.";
|
|
|
|
elements.appIcon.src = absoluteUrl(sourceApp.iconURL || "assets/icon.png");
|
|
elements.appTitle.textContent = sourceApp.name;
|
|
elements.sourceName.textContent = sourcePayload.name || sourceApp.name;
|
|
elements.appSubtitle.textContent = sourceApp.subtitle || "Modern PlayStation 2 emulation for iOS.";
|
|
elements.heroDescription.textContent = firstParagraph(sourceApp.localizedDescription)
|
|
|| "Add ARMSX2 iOS to LiveContainer, SideStore, or Feather to install the latest build.";
|
|
elements.sourceUrl.textContent = canonicalSourceUrl;
|
|
elements.version.textContent = currentVersion.version || "Unavailable";
|
|
elements.date.textContent = currentVersion.date || "Unavailable";
|
|
elements.minimumOs.textContent = currentVersion.minOSVersion || "Device dependent";
|
|
elements.minimumOsRequirement.textContent = currentVersion.minOSVersion
|
|
? `Requires iOS ${currentVersion.minOSVersion} or later.`
|
|
: "Minimum iOS version depends on the current build.";
|
|
elements.whatsNewVersion.textContent = currentVersion.version
|
|
? `Version ${currentVersion.version}`
|
|
: "Latest version";
|
|
|
|
const appDescriptionBlocks = formattedBlocks(sourceApp.localizedDescription);
|
|
|
|
renderScreenshots(sourceApp.screenshotURLs, sourceApp.name);
|
|
renderWhatsNew(elements.whatsNew, currentVersion.localizedDescription);
|
|
renderFeatureHighlights(elements.features, appDescriptionBlocks);
|
|
renderLegalDetails(elements.legalDetails, appDescriptionBlocks);
|
|
};
|
|
|
|
const renderIntegrity = (checksumPayload) => {
|
|
const checksumEntry = checksumPayload.files?.find((checksumFile) => {
|
|
if (!latestVersionManifest) {
|
|
return false;
|
|
}
|
|
|
|
return checksumFile.downloadURL === latestVersionManifest.downloadURL
|
|
|| checksumFile.version === latestVersionManifest.version;
|
|
});
|
|
|
|
if (!checksumEntry) {
|
|
elements.integritySummary.textContent = "Integrity metadata is temporarily unavailable.";
|
|
return;
|
|
}
|
|
|
|
clearElement(elements.integritySummary);
|
|
|
|
const integrityItems = [
|
|
`SHA-256 ${checksumEntry.sha256?.slice(0, 16) || "unavailable"}...`,
|
|
formatBytes(checksumEntry.size),
|
|
checksumEntry.buildVersion ? `Build ${checksumEntry.buildVersion}` : "",
|
|
checksumPayload.generatedAt ? `Generated ${checksumPayload.generatedAt.slice(0, 10)}` : "",
|
|
].filter(Boolean);
|
|
|
|
for (const integrityItem of integrityItems) {
|
|
const integrityElement = document.createElement("span");
|
|
|
|
if (integrityItem.startsWith("SHA-256")) {
|
|
const codeElement = document.createElement("code");
|
|
codeElement.textContent = integrityItem;
|
|
integrityElement.append(codeElement);
|
|
} else {
|
|
integrityElement.textContent = integrityItem;
|
|
}
|
|
|
|
elements.integritySummary.append(integrityElement);
|
|
}
|
|
};
|
|
|
|
const loadSource = async () => {
|
|
try {
|
|
const sourceResponse = await fetch(sourceRequestUrl, {
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
|
|
if (!sourceResponse.ok) {
|
|
throw new Error(`Source request failed: ${sourceResponse.status}`);
|
|
}
|
|
|
|
renderSource(await sourceResponse.json());
|
|
} catch (sourceError) {
|
|
const message = sourceError instanceof Error ? sourceError.message : String(sourceError);
|
|
console.error("Failed to load apps.json:", sourceError);
|
|
elements.sourceUrl.textContent = canonicalSourceUrl;
|
|
elements.version.textContent = "Unavailable";
|
|
elements.date.textContent = "Unavailable";
|
|
elements.minimumOs.textContent = "Unavailable";
|
|
elements.appSubtitle.textContent = "Install details are temporarily unavailable.";
|
|
elements.heroDescription.textContent = "Try again in a moment or copy the install link manually.";
|
|
elements.whatsNewVersion.textContent = "Release notes unavailable";
|
|
elements.whatsNew.textContent = "apps.json could not be loaded.";
|
|
elements.features.textContent = "apps.json could not be loaded.";
|
|
elements.legalDetails.textContent = "apps.json could not be loaded.";
|
|
elements.status.textContent = `Install details could not be loaded: ${message}`;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const checksumResponse = await fetch(checksumsRequestUrl, {
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
|
|
if (!checksumResponse.ok) {
|
|
throw new Error(`Checksum request failed: ${checksumResponse.status}`);
|
|
}
|
|
|
|
renderIntegrity(await checksumResponse.json());
|
|
} catch (checksumError) {
|
|
const message = checksumError instanceof Error ? checksumError.message : String(checksumError);
|
|
console.error("Failed to load checksums.json:", checksumError);
|
|
elements.integritySummary.textContent = "Integrity metadata is temporarily unavailable.";
|
|
elements.status.textContent = `Integrity metadata could not be loaded: ${message}`;
|
|
}
|
|
};
|
|
|
|
document.querySelector("[data-action='livecontainer']").addEventListener("click", () => {
|
|
window.location.href = `livecontainer://source?url=${encodeURIComponent(canonicalSourceUrl)}`;
|
|
});
|
|
|
|
document.querySelector("[data-action='sidestore']").addEventListener("click", () => {
|
|
window.location.href = `sidestore://source?url=${encodeURIComponent(canonicalSourceUrl)}`;
|
|
});
|
|
|
|
document.querySelector("[data-action='feather']").addEventListener("click", () => {
|
|
window.location.href = `feather://source?url=${encodeURIComponent(canonicalSourceUrl)}`;
|
|
});
|
|
|
|
document.querySelector("[data-action='copy']").addEventListener("click", async () => {
|
|
try {
|
|
await writeClipboard(canonicalSourceUrl);
|
|
elements.status.textContent = "Install link copied.";
|
|
} catch (clipboardError) {
|
|
const message = clipboardError instanceof Error ? clipboardError.message : String(clipboardError);
|
|
console.error("Failed to copy install link:", clipboardError);
|
|
elements.status.textContent = `Copy failed: ${message}`;
|
|
}
|
|
});
|
|
|
|
loadSource();
|
|
</script>
|
|
</body>
|
|
</html>
|